Add OAuth authorization page
This commit is contained in:
@@ -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">
|
||||
Selected {$nav.selection.size} files
|
||||
{#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">
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user