Add OAuth authorization page

This commit is contained in:
2026-10-05 13:46:24 +02:00
parent a513e731c4
commit 93d5583940
10 changed files with 288 additions and 35 deletions

View File

@@ -16,10 +16,13 @@ let show_hidden = $state(false)
let {
callback,
select_multiple = false
select_multiple = false,
directories = false,
}: {
callback: (files: FSNode[]) => void
select_multiple?: boolean
// Picks the directory which is open, instead of files in it
directories?: boolean
} = $props();
export const open = (path: string) => {
@@ -36,13 +39,13 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
orig.preventDefault()
if (nav.children[index].type === "dir") {
nav.navigate(nav.children[index].path, true)
} else {
} else if (!directories) {
select_node(index, orig)
}
break
case FileAction.Context:
// 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()
select_node(index, orig)
}
@@ -70,7 +73,9 @@ const select_node = (index: number, e: Event) => {
}
let done = () => {
if (nav.selection.size > 0) {
if (directories) {
callback([nav.base])
} else if (nav.selection.size > 0) {
callback([...nav.selection.values()])
}
modal.hide()
@@ -101,7 +106,11 @@ onMount(() => {
</button>
<div class="title">
{#if directories}
Open the directory to pick
{:else}
Selected {$nav.selection.size} files
{/if}
</div>
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">

View File

@@ -142,28 +142,10 @@ export type NodeOptions = {
// API methods
// ===========
// mkdir only supports the "mode" option
export const fs_mkdir = async (path: string, opts?: NodeOptions) => {
export const fs_mkdir = async (path: string) => {
const form = new FormData()
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(
await fetch(fs_path_url(path), { method: "POST", body: form })
)

View 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/>

View File

@@ -2,7 +2,8 @@
import { onMount } from "svelte";
import { loading_run } from "lib/Loading";
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 CopyButton from "layout/CopyButton.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
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
// takes care of the plurals
const relative = new Intl.RelativeTimeFormat("en", {numeric: "always", style: "short"})
@@ -70,10 +117,16 @@ const load_keys = async () => {
const create_key = async () => {
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(
await fetch(get_endpoint() + "/user/session", {
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
@@ -149,6 +202,13 @@ onMount(load_keys)
<span class="badge">This session</span>
{/if}
<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)}>
<i class="icon">delete</i> Delete
</button>
@@ -168,13 +228,61 @@ onMount(load_keys)
<CopyButton text={key.auth_key}>Copy</CopyButton>
</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">
{#each components as [name, icon, read, write]}
<span class:none={!key[read] && !key[write]}>
<i class="icon small">{icon}</i>
{name}:
{#if key[read] && key[write]}
read and write
read / write
{:else if key[read]}
read only
{:else if key[write]}
@@ -193,6 +301,7 @@ onMount(load_keys)
{/each}
</div>
{/if}
{/if}
<div class="details">
<div>
@@ -223,6 +332,8 @@ onMount(load_keys)
{/each}
</section>
<FilePicker bind:this={picker} callback={pick_dir} directories/>
<style>
.toolbar {
display: flex;
@@ -253,11 +364,17 @@ onMount(load_keys)
layout would put the icon on the baseline of the text */
.app,
.permissions > span,
.row > a {
.row > a,
.edit label,
.edit span {
display: inline-flex;
align-items: center;
gap: 0.3em;
}
/* The name of a permission, in front of its checkboxes */
.name {
min-width: 6em;
}
.app {
font-size: 1.1em;
}

View File

@@ -15,7 +15,7 @@ let billed = $derived(billable && $user.usage_based_billing)
let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer_cap)
</script>
<ProgressBar total={total} used={used}></ProgressBar>
<ProgressBar total={total} used={used} warn></ProgressBar>
<div class="gauge_labels">
<div>
{formatDataVolume(used, 3)}

View File

@@ -122,7 +122,7 @@ onMount(async () => {
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p>
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<ProgressBar used={transfer_used} total={transfer_cap*1e12} warn></ProgressBar>
<p>
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

View File

@@ -13,7 +13,7 @@ let billable = $derived($user !== null && $user.subscription.price_per_tb_storag
let billed = $derived(billable && $user.usage_based_billing)
</script>
<ProgressBar total={total} used={used}></ProgressBar>
<ProgressBar total={total} used={used} warn></ProgressBar>
<div class="gauge_labels">
<div>
{formatDataVolume(used, 3)}

View File

@@ -6,6 +6,7 @@ let {
speed = 1000,
no_animation = false,
no_margin = false,
warn = false,
style = ""
}: {
total?: number;
@@ -14,6 +15,8 @@ let {
speed?: number;
no_animation?: boolean;
no_margin?: boolean;
// Turn yellow and red when the bar is nearly full, for quotas
warn?: boolean;
style?: string;
} = $props();
@@ -37,8 +40,8 @@ $effect(() => {
<div
class="progress_bar_inner"
class:no_animation
class:yellow={percent > 80}
class:red={percent > 90}
class:yellow={warn && percent > 80}
class:red={warn && percent > 90}
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
</div>
</div>

View File

@@ -224,7 +224,7 @@ const set_offset = (off: number) => {
{formatDataVolume($user.subscription.storage_limit, 3)}
</div>
<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>Transfer</div>
<div>
@@ -232,7 +232,7 @@ const set_offset = (off: number) => {
{formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
</div>
<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>
{/snippet}

View File

@@ -27,6 +27,12 @@ let pages: Tab[] = [
path: "/register",
title: "Register",
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",
prefix: "/user/",