Redesign permissions page
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 logout = async (key) => {
|
||||
loading_start()
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<br/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user