Redesign permissions page

This commit is contained in:
2026-10-02 19:33:58 +02:00
parent d5772c24e5
commit a513e731c4
2 changed files with 305 additions and 149 deletions

View File

@@ -95,6 +95,22 @@ export type UserSession = {
creation_time: string, creation_time: string,
last_used_time: string, last_used_time: string,
valid_domains: string[], valid_domains: string[],
// Whether this is the key the list of sessions was requested with
current: boolean,
// What the key is allowed to do
filesystem_read: boolean,
filesystem_write: boolean,
account_read: boolean,
account_write: boolean,
session_read: boolean,
session_write: boolean,
billing_read: boolean,
billing_write: boolean,
// IDs of the directories the key is limited to. Empty means no limit
filesystem_dirs: string[],
} }
// If cached_user is undefined it means that the value is not initialized yet, // If cached_user is undefined it means that the value is not initialized yet,

View File

@@ -1,187 +1,327 @@
<script> <script lang="ts">
import { preventDefault } from 'svelte/legacy'; import { onMount } from "svelte";
import { loading_finish, loading_start } from "lib/Loading"; 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 { 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";
let loaded = $state(false) // Each part of the API has a read and a write permission
let rows = $state([]) const components: [string, string, keyof UserSession, keyof UserSession][] = [
["Files", "folder", "filesystem_read", "filesystem_write"],
["Account", "person", "account_read", "account_write"],
["API keys", "vpn_key", "session_read", "session_write"],
["Billing", "receipt", "billing_read", "billing_write"],
]
let keys: UserSession[] = $state([])
// The keys which are shown in full, the others are masked
let shown: {[key: string]: boolean} = $state({})
// Names of the directories which keys are limited to, by directory ID
let dir_names: {[id: string]: string} = $state({})
// The keys of which the whole user agent is shown
let agent_shown: {[key: string]: boolean} = $state({})
// 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"})
// The server records the use of a key once per hour, so for the last used time
// anything more exact than that would be made up
const ago = (time: string) => {
const seconds = (Date.now() - Date.parse(time)) / 1000
if (seconds >= 86400) {
return relative.format(-Math.floor(seconds / 86400), "day")
} else if (seconds >= 3600) {
return relative.format(-Math.floor(seconds / 3600), "hour")
} else if (seconds >= 60) {
return relative.format(-Math.floor(seconds / 60), "minute")
}
return "just now"
}
const load_keys = async () => { const load_keys = async () => {
loading_start()
try { try {
const resp = await fetch(window.api_endpoint+"/user/session") const resp: UserSession[] = await loading_run(async () => check_response(
if(resp.status >= 400) { await fetch(get_endpoint() + "/user/session")
let json = await resp.json() ))
if (json.value === "authentication_failed") {
window.location = "/login" // The key of this session goes first, then the most recently used ones
return keys = resp.sort((a, b) =>
} else { Number(b.current) - Number(a.current) ||
throw new Error(json.message) Date.parse(b.last_used_time) - Date.parse(a.last_used_time)
} )
}
rows = await resp.json()
rows.forEach(row => {
row.creation_date = new Date(row.creation_time)
row.last_used_date = new Date(row.last_used_time)
})
rows.sort((a, b) => {
return b.last_used_date - a.last_used_date
});
} catch (err) { } catch (err) {
alert(err) alert("Failed to load API keys: " + (err.message ?? err))
} finally { return
loading_finish()
loaded = true
} }
};
for (const id of new Set(keys.flatMap(key => key.filesystem_dirs))) {
if (dir_names[id] === undefined) {
// If the directory is gone the ID is shown instead
fs_get_node("/" + id).then(p => dir_names[id] = p.path[0].name, () => {})
}
}
}
const create_key = async () => { const create_key = async () => {
loading_start()
try { try {
let form = new FormData() const key: UserSession = await loading_run(async () => check_response(
form.append("app_name", "website keys page") await fetch(get_endpoint() + "/user/session", {
const resp = await fetch(
window.api_endpoint+"/user/session",
{
method: "POST", method: "POST",
body: form, body: dict_to_form({app_name: "website keys page"}),
} })
); ))
if(resp.status >= 400) { shown[key.auth_key] = true
throw new Error(await resp.text());
}
} catch (err) { } catch (err) {
alert("Failed to create new API key! "+err) alert("Failed to create new API key: " + (err.message ?? err))
} finally {
loading_finish()
} }
load_keys(); await load_keys()
} }
const logout = async (key) => { const delete_key = async (key: UserSession) => {
loading_start() if (key.current && !confirm(
"This is the key you are logged in with. Deleting it logs you out. Continue?"
)) {
return
}
try { try {
const resp = await fetch( // A key is deleted by logging out with it
window.api_endpoint+"/user/session", await loading_run(async () => check_response(
{ await fetch(get_endpoint() + "/user/session", {
method: "DELETE", method: "DELETE",
headers: { headers: {"Authorization": "Basic " + btoa(":" + key.auth_key)},
"Authorization": "Basic "+btoa(":"+key), })
} ))
}
);
if(resp.status >= 400) {
throw new Error(await resp.text());
}
} catch (err) { } catch (err) {
alert("Failed to delete key: "+err) alert("Failed to delete key: " + (err.message ?? err))
} finally {
loading_finish()
} }
load_keys(); if (key.current) {
window.location.href = "/login"
} else {
await load_keys()
}
} }
onMount(load_keys)
</script> </script>
<section> <section>
{#if !loaded}
<div class="highlight_yellow">
<h2>Warning</h2>
<p>
API keys are sensitive information. They can be used to gain
full control over your account. Do not show your API keys to
someone or something you don't trust!
</p>
<button class="button_red" onclick={load_keys}>
<i class="icon">lock_open</i> Show API keys
</button>
</div>
{:else}
<div class="toolbar" style="text-align: left;">
<div class="toolbar_spacer"></div>
<button onclick={create_key}>
<i class="icon">add</i> Create new API key
</button>
</div>
{/if}
<p> <p>
If you delete the API key that you are currently using you will be API keys give apps access to your account. Whoever has a key can do
logged out of your account. Keys are sorted based on how recently they everything the key is allowed to do, so don't show them to someone or
were used, so your session usually the top one. API keys expire 30 days something you don't trust. A key expires 30 days after it was last used.
after the last time they're used. If you think someone is using your If you think someone is using your account without your permission,
account without your authorization it's probably a good idea to delete delete all your keys and change your password.
all your keys.
</p> </p>
</section>
<div class="table_scroll"> <div class="toolbar">
<table style="text-align: left;"> <div class="toolbar_spacer"></div>
<thead> <button onclick={create_key}>
<tr> <i class="icon">add</i> Create new API key
<td>Key</td> </button>
<td>Created</td> </div>
<td>Last used ▼</td>
<td>IP address</td> {#each keys as key (key.auth_key)}
<td></td> <div class="key" class:current={key.current}>
</tr> <div class="row">
</thead> <span class="app">
<tbody> {#if key.app_name === "website login"}
{#each rows as row (row.auth_key)} <NovaLogo style="height: 1.5em;"/> Nova website
<tr style="border-bottom: none;"> {:else if key.app_name === "website keys page"}
<td> <i class="icon">vpn_key</i> Created on this page
<CopyButton text={row.auth_key} small_icon>Copy</CopyButton> {:else if key.app_name === "sharex"}
{row.auth_key} <img src="/res/img/sharex.png" alt=""/> ShareX
</td> {:else if key.app_name === "jdownloader"}
<td>{formatDate(row.creation_time, true, true, false)}</td> <img src="/res/img/jdownloader.png" alt=""/> JDownloader
<td>{formatDate(row.last_used_time, true, true, false)}</td> {:else}
<td>{row.creation_ip_address}</td> <i class="icon">apps</i> {key.app_name || "Unnamed key"}
<td> {/if}
<button onclick={(e) => {e.preventDefault();logout(row.auth_key)}} class="button button_red round"> </span>
<i class="icon">delete</i> {#if key.current}
</button> <span class="badge">This session</span>
</td> {/if}
</tr> <div class="toolbar_spacer"></div>
<tr> <button class="button_red" onclick={() => delete_key(key)}>
<td colspan="1"> <i class="icon">delete</i> Delete
{#if row.app_name === "website login"} </button>
<NovaLogo style="height: 1.6em; vertical-align: middle;"/> </div>
Nova website
{:else if row.app_name === "website keys page"} <div class="row">
<i class="icon">vpn_key</i> <code>
Nova keys page {shown[key.auth_key] ? key.auth_key : key.auth_key.slice(0, 8) + "-••••-••••-••••-••••••••••••"}
{:else if row.app_name === "sharex"} </code>
<img src="/res/img/sharex.png" alt="ShareX logo" class="app_icon"/> <button
ShareX class:button_highlight={shown[key.auth_key]}
{:else if row.app_name === "jdownloader"} onclick={() => shown[key.auth_key] = !shown[key.auth_key]}
<img src="/res/img/jdownloader.png" alt="JDownloader logo" class="app_icon"/> >
JDownloader <i class="icon">{shown[key.auth_key] ? "visibility_off" : "visibility"}</i>
{shown[key.auth_key] ? "Hide" : "Show"}
</button>
<CopyButton text={key.auth_key}>Copy</CopyButton>
</div>
<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
{:else if key[read]}
read only
{:else if key[write]}
write only
{:else} {:else}
Unknown app: {row.app_name} no access
{/if} {/if}
</td> </span>
<td colspan="4">User-Agent: {row.user_agent}</td> {/each}
</tr> </div>
{/each} {#if key.filesystem_dirs.length !== 0}
</tbody> <div class="row">
</table> <span>Files access is limited to</span>
<br/> {#each key.filesystem_dirs as id}
</div> <a href="/d/{id}"><i class="icon small">folder</i>{dir_names[id] ?? id}</a>
{/each}
</div>
{/if}
<div class="details">
<div>
<span>Created</span>
{formatDate(key.creation_time, true, true, false)}
({ago(key.creation_time)})
</div>
<div>
<span>Last used</span>
{formatDate(key.last_used_time, true, true, false)}
({ago(key.last_used_time)})
</div>
<div>
<span>IP address</span>
{key.creation_ip_address}
</div>
<div class="agent" class:agent_shown={agent_shown[key.auth_key]}>
<span>User agent</span>
<button
aria-expanded={!!agent_shown[key.auth_key]}
onclick={() => agent_shown[key.auth_key] = !agent_shown[key.auth_key]}
>
{key.user_agent}
</button>
</div>
</div>
</div>
{/each}
</section>
<style> <style>
.toolbar { .toolbar {
display: flex; display: flex;
flex-direction: row;
width: 100%;
} }
.toolbar > * { flex: 0 0 auto; } .toolbar_spacer {
.toolbar_spacer { flex: 1 1 auto; } flex: 1 1 auto;
}
.app_icon { .key {
height: 1.6em; margin: 0.8em 0;
vertical-align: middle; padding: 0.4em 0.6em;
border-radius: 8px;
border-left: 3px solid var(--separator);
background: var(--card_color);
}
.key.current {
border-left-color: var(--accent_color);
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2em 0.5em;
margin: 0.3em 0;
}
/* An icon with text next to it. Flex centres the two on each other, inline
layout would put the icon on the baseline of the text */
.app,
.permissions > span,
.row > a {
display: inline-flex;
align-items: center;
gap: 0.3em;
}
.app {
font-size: 1.1em;
}
.app > img {
height: 1.5em;
}
.badge {
padding: 0 0.6em;
border-radius: 1em;
color: var(--link_color);
background: var(--accent_shade);
}
/* The key takes the space the buttons leave, and is cut off when it doesn't
fit. It can still be copied in full */
code {
flex: 1 1 10em;
padding: 0.3em 0.5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.permissions > span {
padding: 0.1em 0.5em;
border: 1px solid var(--separator);
border-radius: 6px;
}
.none,
.details span {
opacity: 0.6;
}
/* Each detail is a label with its value below it */
.details {
display: flex;
flex-wrap: wrap;
gap: 0.2em 1.5em;
font-size: 0.9em;
}
.details > div {
display: flex;
flex-direction: column;
}
/* The user agent gets the space which is left, and is cut off until it is
clicked. It's a button so it works with a keyboard too, without the looks of
one */
.agent {
flex: 1 1 10em;
min-width: 0;
}
.agent > button {
all: unset;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.agent_shown {
flex-basis: 100%;
}
.agent_shown > button {
white-space: normal;
overflow-wrap: anywhere;
} }
</style> </style>