Redesign permissions page
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user