Add OAuth authorization page
This commit is contained in:
@@ -16,10 +16,13 @@ let show_hidden = $state(false)
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
callback,
|
callback,
|
||||||
select_multiple = false
|
select_multiple = false,
|
||||||
|
directories = false,
|
||||||
}: {
|
}: {
|
||||||
callback: (files: FSNode[]) => void
|
callback: (files: FSNode[]) => void
|
||||||
select_multiple?: boolean
|
select_multiple?: boolean
|
||||||
|
// Picks the directory which is open, instead of files in it
|
||||||
|
directories?: boolean
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
export const open = (path: string) => {
|
export const open = (path: string) => {
|
||||||
@@ -36,13 +39,13 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
|
|||||||
orig.preventDefault()
|
orig.preventDefault()
|
||||||
if (nav.children[index].type === "dir") {
|
if (nav.children[index].type === "dir") {
|
||||||
nav.navigate(nav.children[index].path, true)
|
nav.navigate(nav.children[index].path, true)
|
||||||
} else {
|
} else if (!directories) {
|
||||||
select_node(index, orig)
|
select_node(index, orig)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case FileAction.Context:
|
case FileAction.Context:
|
||||||
// If this is a touch event we will select the item
|
// If this is a touch event we will select the item
|
||||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
if (!directories && navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
||||||
orig.preventDefault()
|
orig.preventDefault()
|
||||||
select_node(index, orig)
|
select_node(index, orig)
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,9 @@ const select_node = (index: number, e: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let done = () => {
|
let done = () => {
|
||||||
if (nav.selection.size > 0) {
|
if (directories) {
|
||||||
|
callback([nav.base])
|
||||||
|
} else if (nav.selection.size > 0) {
|
||||||
callback([...nav.selection.values()])
|
callback([...nav.selection.values()])
|
||||||
}
|
}
|
||||||
modal.hide()
|
modal.hide()
|
||||||
@@ -101,7 +106,11 @@ onMount(() => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
|
{#if directories}
|
||||||
|
Open the directory to pick
|
||||||
|
{:else}
|
||||||
Selected {$nav.selection.size} files
|
Selected {$nav.selection.size} files
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">
|
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">
|
||||||
|
|||||||
@@ -142,28 +142,10 @@ export type NodeOptions = {
|
|||||||
// API methods
|
// API methods
|
||||||
// ===========
|
// ===========
|
||||||
|
|
||||||
// mkdir only supports the "mode" option
|
export const fs_mkdir = async (path: string) => {
|
||||||
export const fs_mkdir = async (path: string, opts?: NodeOptions) => {
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append("action", "mkdir")
|
form.append("action", "mkdir")
|
||||||
|
|
||||||
if (opts !== undefined && opts.mode !== undefined) {
|
|
||||||
form.append("mode", opts.mode.toFixed(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fs_check_response(
|
|
||||||
await fetch(fs_path_url(path), { method: "POST", body: form })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fs_mkdirall = async (path: string, opts: NodeOptions) => {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append("action", "mkdirall")
|
|
||||||
|
|
||||||
if (opts && opts.mode) {
|
|
||||||
form.append("mode", opts.mode.toFixed(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fs_check_response(
|
return await fs_check_response(
|
||||||
await fetch(fs_path_url(path), { method: "POST", body: form })
|
await fetch(fs_path_url(path), { method: "POST", body: form })
|
||||||
)
|
)
|
||||||
|
|||||||
136
svelte/src/login/Authorize.svelte
Normal file
136
svelte/src/login/Authorize.svelte
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { loading_run } from "lib/Loading";
|
||||||
|
import { check_response, get_endpoint, get_user } from "lib/NovaAPI";
|
||||||
|
import type { FSNode } from "lib/FilesystemAPI.svelte";
|
||||||
|
import FilePicker from "filesystem/filemanager/FilePicker.svelte";
|
||||||
|
|
||||||
|
// An app sends the user here with an OAuth authorization request in the query
|
||||||
|
// string. The API gets it exactly as it came in, first to check it and then to
|
||||||
|
// approve it. Everything this page shows and every address it goes to comes
|
||||||
|
// out of the API's answer, the page itself never reads the request. The API
|
||||||
|
// and the browser can disagree about a query with the same parameter in it
|
||||||
|
// twice, and then the user would approve something else than what they see
|
||||||
|
const endpoint = get_endpoint() + "/user/oauth/authorize" + window.location.search
|
||||||
|
|
||||||
|
// What the app can do with each permission it can ask for
|
||||||
|
const permissions: {[name: string]: string} = {
|
||||||
|
filesystem_read: "See and download your files",
|
||||||
|
filesystem_write: "Upload, change, share and delete your files",
|
||||||
|
account_read: `See your account details, like your e-mail address and how
|
||||||
|
much storage you use`,
|
||||||
|
session_read: "See your API keys, except the ones which can do more than its own",
|
||||||
|
session_write: "Create API keys",
|
||||||
|
billing_read: "See your invoices and transactions",
|
||||||
|
billing_write: "Change your subscription and create invoices",
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = $state("")
|
||||||
|
let request: {client_name: string, scope: string[], redirect_uri: string} = $state(null)
|
||||||
|
let errors: string[] = $state([])
|
||||||
|
|
||||||
|
// The directories the user limits the app to. With none the app can use all
|
||||||
|
// files
|
||||||
|
let dirs: FSNode[] = $state([])
|
||||||
|
let picker: FilePicker = $state()
|
||||||
|
const pick = (nodes: FSNode[]) => {
|
||||||
|
if (!dirs.some(dir => dir.id === nodes[0].id)) {
|
||||||
|
dirs.push(nodes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = async (method: string) => {
|
||||||
|
try {
|
||||||
|
// The directories go in the body. The query is what the app asks for,
|
||||||
|
// and the API refuses a request which has them in there
|
||||||
|
const body = method !== "POST" ? undefined : new URLSearchParams({
|
||||||
|
filesystem_dirs: JSON.stringify(dirs.map(dir => dir.id)),
|
||||||
|
})
|
||||||
|
return await loading_run(async () => check_response(
|
||||||
|
await fetch(endpoint, {method: method, body: body})
|
||||||
|
))
|
||||||
|
} catch (err) {
|
||||||
|
// A request with several things wrong with it comes back as a list
|
||||||
|
errors = (err.errors ?? [err]).map((e: any) =>
|
||||||
|
(e.message ?? e) + (e.extra?.field ? " (" + e.extra.field + ")" : "")
|
||||||
|
)
|
||||||
|
request = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const approve = async () => {
|
||||||
|
const approved = await authorize("POST")
|
||||||
|
if (approved !== undefined) {
|
||||||
|
window.location.href = approved.redirect_uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
username = (await get_user()).username
|
||||||
|
request = await authorize("GET")
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page_content">
|
||||||
|
<section>
|
||||||
|
{#if errors.length !== 0}
|
||||||
|
<h2>This app can't be given access</h2>
|
||||||
|
{#each errors as error}
|
||||||
|
<p>{error}</p>
|
||||||
|
{/each}
|
||||||
|
{:else if request}
|
||||||
|
<h2>{request.client_name} wants access to your account</h2>
|
||||||
|
<p>
|
||||||
|
You are logged in as {username}. If you approve,
|
||||||
|
{request.client_name} will be able to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{#each request.scope as name}
|
||||||
|
<li>{permissions[name]}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if request.scope.some(name => name.startsWith("filesystem_"))}
|
||||||
|
{#if dirs.length === 0}
|
||||||
|
<p>
|
||||||
|
That goes for all your files. You can limit
|
||||||
|
{request.client_name} to the directories you pick.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>{request.client_name} can only use the files in:</p>
|
||||||
|
<ul>
|
||||||
|
{#each dirs as dir, index (dir.id)}
|
||||||
|
<li>
|
||||||
|
{dir.path}
|
||||||
|
<button onclick={() => dirs.splice(index, 1)} title="Remove">
|
||||||
|
<i class="icon">close</i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
<p>
|
||||||
|
<button onclick={() => picker.open("/me")}>
|
||||||
|
<i class="icon">folder</i>
|
||||||
|
{dirs.length === 0 ? "Pick directories" : "Add a directory"}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You can take the access away again by deleting the key of the
|
||||||
|
app on the <a href="/user/api_keys">API keys page</a>.
|
||||||
|
</p>
|
||||||
|
<button class="button_highlight" onclick={approve}>
|
||||||
|
<i class="icon">check</i> Approve
|
||||||
|
</button>
|
||||||
|
<!-- For a request which was not approved the address has an error
|
||||||
|
in it, which tells the app that the user said no -->
|
||||||
|
<a class="button" href={request.redirect_uri} rel="external">
|
||||||
|
<i class="icon">close</i> Deny
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilePicker bind:this={picker} callback={pick} directories/>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { loading_run } from "lib/Loading";
|
import { loading_run } from "lib/Loading";
|
||||||
import { check_response, dict_to_form, get_endpoint, type UserSession } from "lib/NovaAPI";
|
import { check_response, dict_to_form, get_endpoint, type UserSession } from "lib/NovaAPI";
|
||||||
import { fs_get_node } from "lib/FilesystemAPI.svelte";
|
import { fs_get_node, type FSNode } from "lib/FilesystemAPI.svelte";
|
||||||
|
import FilePicker from "filesystem/filemanager/FilePicker.svelte";
|
||||||
import { formatDate } from "util/Formatting";
|
import { formatDate } from "util/Formatting";
|
||||||
import CopyButton from "layout/CopyButton.svelte";
|
import CopyButton from "layout/CopyButton.svelte";
|
||||||
import NovaLogo from "util/NovaLogo.svelte";
|
import NovaLogo from "util/NovaLogo.svelte";
|
||||||
@@ -26,6 +27,52 @@ let dir_names: {[id: string]: string} = $state({})
|
|||||||
// The keys of which the whole user agent is shown
|
// The keys of which the whole user agent is shown
|
||||||
let agent_shown: {[key: string]: boolean} = $state({})
|
let agent_shown: {[key: string]: boolean} = $state({})
|
||||||
|
|
||||||
|
// The key which is being edited, and what its permissions and directories are
|
||||||
|
// going to be. Nothing changes until it is saved
|
||||||
|
let editing = $state("")
|
||||||
|
let edit_perms: {[name: string]: boolean} = $state({})
|
||||||
|
let edit_dirs: string[] = $state([])
|
||||||
|
let picker: FilePicker = $state()
|
||||||
|
|
||||||
|
const edit_key = (key: UserSession) => {
|
||||||
|
editing = key.auth_key
|
||||||
|
edit_dirs = [...key.filesystem_dirs]
|
||||||
|
edit_perms = {}
|
||||||
|
for (const [, , read, write] of components) {
|
||||||
|
edit_perms[read] = key[read] as boolean
|
||||||
|
edit_perms[write] = key[write] as boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick_dir = (nodes: FSNode[]) => {
|
||||||
|
const dir = nodes[0]
|
||||||
|
dir_names[dir.id] = dir.name
|
||||||
|
if (!edit_dirs.includes(dir.id)) {
|
||||||
|
edit_dirs.push(dir.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save_key = async () => {
|
||||||
|
try {
|
||||||
|
await loading_run(async () => check_response(
|
||||||
|
await fetch(get_endpoint() + "/user/session", {
|
||||||
|
method: "PUT",
|
||||||
|
body: dict_to_form({
|
||||||
|
auth_key: editing,
|
||||||
|
...edit_perms,
|
||||||
|
filesystem_dirs: edit_dirs,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
))
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to change key: " + (err.message ?? err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editing = ""
|
||||||
|
await load_keys()
|
||||||
|
}
|
||||||
|
|
||||||
// Intl.RelativeTimeFormat is the browser's formatter for "5 minutes ago", it
|
// Intl.RelativeTimeFormat is the browser's formatter for "5 minutes ago", it
|
||||||
// takes care of the plurals
|
// takes care of the plurals
|
||||||
const relative = new Intl.RelativeTimeFormat("en", {numeric: "always", style: "short"})
|
const relative = new Intl.RelativeTimeFormat("en", {numeric: "always", style: "short"})
|
||||||
@@ -70,10 +117,16 @@ const load_keys = async () => {
|
|||||||
|
|
||||||
const create_key = async () => {
|
const create_key = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Keys are made to be put in other programs, where they can leak. With
|
||||||
|
// account write a key can change the password and the e-mail address,
|
||||||
|
// which is enough to take the account over, so these don't get it
|
||||||
const key: UserSession = await loading_run(async () => check_response(
|
const key: UserSession = await loading_run(async () => check_response(
|
||||||
await fetch(get_endpoint() + "/user/session", {
|
await fetch(get_endpoint() + "/user/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: dict_to_form({app_name: "website keys page"}),
|
body: dict_to_form({
|
||||||
|
app_name: "website keys page",
|
||||||
|
account_write: false,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
))
|
))
|
||||||
shown[key.auth_key] = true
|
shown[key.auth_key] = true
|
||||||
@@ -149,6 +202,13 @@ onMount(load_keys)
|
|||||||
<span class="badge">This session</span>
|
<span class="badge">This session</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="toolbar_spacer"></div>
|
<div class="toolbar_spacer"></div>
|
||||||
|
<!-- Not the session which is in use. Without its permissions
|
||||||
|
this page and the rest of the website stop working -->
|
||||||
|
{#if !key.current && editing !== key.auth_key}
|
||||||
|
<button onclick={() => edit_key(key)}>
|
||||||
|
<i class="icon">edit</i> Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button class="button_red" onclick={() => delete_key(key)}>
|
<button class="button_red" onclick={() => delete_key(key)}>
|
||||||
<i class="icon">delete</i> Delete
|
<i class="icon">delete</i> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -168,13 +228,61 @@ onMount(load_keys)
|
|||||||
<CopyButton text={key.auth_key}>Copy</CopyButton>
|
<CopyButton text={key.auth_key}>Copy</CopyButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if editing === key.auth_key}
|
||||||
|
<div class="edit">
|
||||||
|
{#each components as [name, icon, read, write]}
|
||||||
|
<div class="row">
|
||||||
|
<span class="name"><i class="icon small">{icon}</i> {name}</span>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={edit_perms[read]}/> read
|
||||||
|
</label>
|
||||||
|
<!-- Account write can be taken away from a key, but
|
||||||
|
not given to one -->
|
||||||
|
{#if write !== "account_write" || key.account_write}
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={edit_perms[write]}/> write
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{#if edit_dirs.length === 0}
|
||||||
|
<span>Files access is not limited to directories</span>
|
||||||
|
{:else}
|
||||||
|
<span>Files access is limited to</span>
|
||||||
|
{#each edit_dirs as id, index (id)}
|
||||||
|
<span>
|
||||||
|
<i class="icon small">folder</i>{dir_names[id] ?? id}
|
||||||
|
<button onclick={() => edit_dirs.splice(index, 1)} title="Remove">
|
||||||
|
<i class="icon small">close</i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => picker.open("/me")}>
|
||||||
|
<i class="icon">folder</i>
|
||||||
|
{edit_dirs.length === 0 ? "Limit to a directory" : "Add a directory"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="button_highlight" onclick={save_key}>
|
||||||
|
<i class="icon">save</i> Save
|
||||||
|
</button>
|
||||||
|
<button onclick={() => editing = ""}>
|
||||||
|
<i class="icon">close</i> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="row permissions">
|
<div class="row permissions">
|
||||||
{#each components as [name, icon, read, write]}
|
{#each components as [name, icon, read, write]}
|
||||||
<span class:none={!key[read] && !key[write]}>
|
<span class:none={!key[read] && !key[write]}>
|
||||||
<i class="icon small">{icon}</i>
|
<i class="icon small">{icon}</i>
|
||||||
{name}:
|
{name}:
|
||||||
{#if key[read] && key[write]}
|
{#if key[read] && key[write]}
|
||||||
read and write
|
read / write
|
||||||
{:else if key[read]}
|
{:else if key[read]}
|
||||||
read only
|
read only
|
||||||
{:else if key[write]}
|
{:else if key[write]}
|
||||||
@@ -193,6 +301,7 @@ onMount(load_keys)
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<div>
|
<div>
|
||||||
@@ -223,6 +332,8 @@ onMount(load_keys)
|
|||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<FilePicker bind:this={picker} callback={pick_dir} directories/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -253,11 +364,17 @@ onMount(load_keys)
|
|||||||
layout would put the icon on the baseline of the text */
|
layout would put the icon on the baseline of the text */
|
||||||
.app,
|
.app,
|
||||||
.permissions > span,
|
.permissions > span,
|
||||||
.row > a {
|
.row > a,
|
||||||
|
.edit label,
|
||||||
|
.edit span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3em;
|
gap: 0.3em;
|
||||||
}
|
}
|
||||||
|
/* The name of a permission, in front of its checkboxes */
|
||||||
|
.name {
|
||||||
|
min-width: 6em;
|
||||||
|
}
|
||||||
.app {
|
.app {
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let billed = $derived(billable && $user.usage_based_billing)
|
|||||||
let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer_cap)
|
let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer_cap)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProgressBar total={total} used={used}></ProgressBar>
|
<ProgressBar total={total} used={used} warn></ProgressBar>
|
||||||
<div class="gauge_labels">
|
<div class="gauge_labels">
|
||||||
<div>
|
<div>
|
||||||
{formatDataVolume(used, 3)}
|
{formatDataVolume(used, 3)}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ onMount(async () => {
|
|||||||
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
|
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
|
||||||
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
|
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
|
||||||
</p>
|
</p>
|
||||||
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
|
<ProgressBar used={transfer_used} total={transfer_cap*1e12} warn></ProgressBar>
|
||||||
<p>
|
<p>
|
||||||
The billshock limit limits how much bandwidth your account can use in a
|
The billshock limit limits how much bandwidth your account can use in a
|
||||||
30 day window. When this limit is reached hotlinking will be disabled
|
30 day window. When this limit is reached hotlinking will be disabled
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ let billable = $derived($user !== null && $user.subscription.price_per_tb_storag
|
|||||||
let billed = $derived(billable && $user.usage_based_billing)
|
let billed = $derived(billable && $user.usage_based_billing)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProgressBar total={total} used={used}></ProgressBar>
|
<ProgressBar total={total} used={used} warn></ProgressBar>
|
||||||
<div class="gauge_labels">
|
<div class="gauge_labels">
|
||||||
<div>
|
<div>
|
||||||
{formatDataVolume(used, 3)}
|
{formatDataVolume(used, 3)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let {
|
|||||||
speed = 1000,
|
speed = 1000,
|
||||||
no_animation = false,
|
no_animation = false,
|
||||||
no_margin = false,
|
no_margin = false,
|
||||||
|
warn = false,
|
||||||
style = ""
|
style = ""
|
||||||
}: {
|
}: {
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -14,6 +15,8 @@ let {
|
|||||||
speed?: number;
|
speed?: number;
|
||||||
no_animation?: boolean;
|
no_animation?: boolean;
|
||||||
no_margin?: boolean;
|
no_margin?: boolean;
|
||||||
|
// Turn yellow and red when the bar is nearly full, for quotas
|
||||||
|
warn?: boolean;
|
||||||
style?: string;
|
style?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -37,8 +40,8 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="progress_bar_inner"
|
class="progress_bar_inner"
|
||||||
class:no_animation
|
class:no_animation
|
||||||
class:yellow={percent > 80}
|
class:yellow={warn && percent > 80}
|
||||||
class:red={percent > 90}
|
class:red={warn && percent > 90}
|
||||||
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
|
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ const set_offset = (off: number) => {
|
|||||||
{formatDataVolume($user.subscription.storage_limit, 3)}
|
{formatDataVolume($user.subscription.storage_limit, 3)}
|
||||||
</div>
|
</div>
|
||||||
<div style="grid-column: span 2">
|
<div style="grid-column: span 2">
|
||||||
<ProgressBar used={$user.storage_space_used} total={$user.subscription.storage_limit}/>
|
<ProgressBar used={$user.storage_space_used} total={$user.subscription.storage_limit} warn/>
|
||||||
</div>
|
</div>
|
||||||
<div>Transfer</div>
|
<div>Transfer</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -232,7 +232,7 @@ const set_offset = (off: number) => {
|
|||||||
{formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
{formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
||||||
</div>
|
</div>
|
||||||
<div style="grid-column: span 2">
|
<div style="grid-column: span 2">
|
||||||
<ProgressBar used={$user.monthly_transfer_used} total={$user.subscription.monthly_transfer_cap}/>
|
<ProgressBar used={$user.monthly_transfer_used} total={$user.subscription.monthly_transfer_cap} warn/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ let pages: Tab[] = [
|
|||||||
path: "/register",
|
path: "/register",
|
||||||
title: "Register",
|
title: "Register",
|
||||||
component: () => import("login/Router.svelte"),
|
component: () => import("login/Router.svelte"),
|
||||||
|
}, {
|
||||||
|
// Apps send users here to ask for access to their account
|
||||||
|
path: "/oauth/authorize",
|
||||||
|
title: "Authorize app",
|
||||||
|
component: () => import("login/Authorize.svelte"),
|
||||||
|
login: true,
|
||||||
}, {
|
}, {
|
||||||
path: "/user",
|
path: "/user",
|
||||||
prefix: "/user/",
|
prefix: "/user/",
|
||||||
|
|||||||
Reference in New Issue
Block a user