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,
last_used_time: 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,

View File

@@ -1,187 +1,327 @@
<script>
import { preventDefault } from 'svelte/legacy';
import { loading_finish, loading_start } from "lib/Loading";
<script lang="ts">
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 { formatDate } from "util/Formatting";
import CopyButton from 'layout/CopyButton.svelte';
import CopyButton from "layout/CopyButton.svelte";
import NovaLogo from "util/NovaLogo.svelte";
let loaded = $state(false)
let rows = $state([])
// Each part of the API has a read and a write permission
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 () => {
loading_start()
try {
const resp = await fetch(window.api_endpoint+"/user/session")
if(resp.status >= 400) {
let json = await resp.json()
if (json.value === "authentication_failed") {
window.location = "/login"
return
} else {
throw new Error(json.message)
}
}
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
});
const resp: UserSession[] = await loading_run(async () => check_response(
await fetch(get_endpoint() + "/user/session")
))
// The key of this session goes first, then the most recently used ones
keys = resp.sort((a, b) =>
Number(b.current) - Number(a.current) ||
Date.parse(b.last_used_time) - Date.parse(a.last_used_time)
)
} catch (err) {
alert(err)
} finally {
loading_finish()
loaded = true
alert("Failed to load API keys: " + (err.message ?? err))
return
}
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 () => {
loading_start()
try {
let form = new FormData()
form.append("app_name", "website keys page")
const resp = await fetch(
window.api_endpoint+"/user/session",
{
const key: UserSession = await loading_run(async () => check_response(
await fetch(get_endpoint() + "/user/session", {
method: "POST",
body: form,
}
);
if(resp.status >= 400) {
throw new Error(await resp.text());
}
body: dict_to_form({app_name: "website keys page"}),
})
))
shown[key.auth_key] = true
} catch (err) {
alert("Failed to create new API key! "+err)
} finally {
loading_finish()
alert("Failed to create new API key: " + (err.message ?? err))
}
load_keys();
await load_keys()
}
const delete_key = async (key: UserSession) => {
if (key.current && !confirm(
"This is the key you are logged in with. Deleting it logs you out. Continue?"
)) {
return
}
const logout = async (key) => {
loading_start()
try {
const resp = await fetch(
window.api_endpoint+"/user/session",
{
// A key is deleted by logging out with it
await loading_run(async () => check_response(
await fetch(get_endpoint() + "/user/session", {
method: "DELETE",
headers: {
"Authorization": "Basic "+btoa(":"+key),
}
}
);
if(resp.status >= 400) {
throw new Error(await resp.text());
}
headers: {"Authorization": "Basic " + btoa(":" + key.auth_key)},
})
))
} catch (err) {
alert("Failed to delete key: "+err)
} finally {
loading_finish()
alert("Failed to delete key: " + (err.message ?? err))
}
load_keys();
if (key.current) {
window.location.href = "/login"
} else {
await load_keys()
}
}
onMount(load_keys)
</script>
<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!
API keys give apps access to your account. Whoever has a key can do
everything the key is allowed to do, so don't show them to someone or
something you don't trust. A key expires 30 days after it was last used.
If you think someone is using your account without your permission,
delete all your keys and change your password.
</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">
<div class="toolbar_spacer"></div>
<button onclick={create_key}>
<i class="icon">add</i> Create new API key
</button>
</div>
{#each keys as key (key.auth_key)}
<div class="key" class:current={key.current}>
<div class="row">
<span class="app">
{#if key.app_name === "website login"}
<NovaLogo style="height: 1.5em;"/> Nova website
{:else if key.app_name === "website keys page"}
<i class="icon">vpn_key</i> Created on this page
{:else if key.app_name === "sharex"}
<img src="/res/img/sharex.png" alt=""/> ShareX
{:else if key.app_name === "jdownloader"}
<img src="/res/img/jdownloader.png" alt=""/> JDownloader
{:else}
<i class="icon">apps</i> {key.app_name || "Unnamed key"}
{/if}
</span>
{#if key.current}
<span class="badge">This session</span>
{/if}
<div class="toolbar_spacer"></div>
<button class="button_red" onclick={() => delete_key(key)}>
<i class="icon">delete</i> Delete
</button>
</div>
<div class="row">
<code>
{shown[key.auth_key] ? key.auth_key : key.auth_key.slice(0, 8) + "-••••-••••-••••-••••••••••••"}
</code>
<button
class:button_highlight={shown[key.auth_key]}
onclick={() => shown[key.auth_key] = !shown[key.auth_key]}
>
<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}
no access
{/if}
</span>
{/each}
</div>
{#if key.filesystem_dirs.length !== 0}
<div class="row">
<span>Files access is limited to</span>
{#each key.filesystem_dirs as id}
<a href="/d/{id}"><i class="icon small">folder</i>{dir_names[id] ?? id}</a>
{/each}
</div>
{/if}
<p>
If you delete the API key that you are currently using you will be
logged out of your account. Keys are sorted based on how recently they
were used, so your session usually the top one. API keys expire 30 days
after the last time they're used. If you think someone is using your
account without your authorization it's probably a good idea to delete
all your keys.
</p>
</section>
<div class="table_scroll">
<table style="text-align: left;">
<thead>
<tr>
<td>Key</td>
<td>Created</td>
<td>Last used ▼</td>
<td>IP address</td>
<td></td>
</tr>
</thead>
<tbody>
{#each rows as row (row.auth_key)}
<tr style="border-bottom: none;">
<td>
<CopyButton text={row.auth_key} small_icon>Copy</CopyButton>
{row.auth_key}
</td>
<td>{formatDate(row.creation_time, true, true, false)}</td>
<td>{formatDate(row.last_used_time, true, true, false)}</td>
<td>{row.creation_ip_address}</td>
<td>
<button onclick={(e) => {e.preventDefault();logout(row.auth_key)}} class="button button_red round">
<i class="icon">delete</i>
</button>
</td>
</tr>
<tr>
<td colspan="1">
{#if row.app_name === "website login"}
<NovaLogo style="height: 1.6em; vertical-align: middle;"/>
Nova website
{:else if row.app_name === "website keys page"}
<i class="icon">vpn_key</i>
Nova keys page
{:else if row.app_name === "sharex"}
<img src="/res/img/sharex.png" alt="ShareX logo" class="app_icon"/>
ShareX
{:else if row.app_name === "jdownloader"}
<img src="/res/img/jdownloader.png" alt="JDownloader logo" class="app_icon"/>
JDownloader
{:else}
Unknown app: {row.app_name}
{/if}
</td>
<td colspan="4">User-Agent: {row.user_agent}</td>
</tr>
{/each}
</tbody>
</table>
<br/>
<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>
.toolbar {
display: flex;
flex-direction: row;
width: 100%;
}
.toolbar > * { flex: 0 0 auto; }
.toolbar_spacer { flex: 1 1 auto; }
.toolbar_spacer {
flex: 1 1 auto;
}
.app_icon {
height: 1.6em;
vertical-align: middle;
.key {
margin: 0.8em 0;
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>