293 lines
8.9 KiB
Vue
293 lines
8.9 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Plus,
|
||
ShieldCheck,
|
||
KeyRound,
|
||
Trash2,
|
||
UsersRound,
|
||
} from "lucide-vue-next";
|
||
interface Account {
|
||
id: string;
|
||
username: string;
|
||
role: string;
|
||
last_login: number | null;
|
||
active_sessions: number;
|
||
}
|
||
interface Session {
|
||
id: string;
|
||
username: string;
|
||
last_seen: number;
|
||
created: number;
|
||
}
|
||
const { t, locale } = useI18n();
|
||
useHead({ title: computed(() => `${t("accounts.title")} · Webka`) });
|
||
const current = useSessionStore();
|
||
const accounts = ref<Account[]>([]),
|
||
sessions = ref<Session[]>([]),
|
||
loading = ref(true),
|
||
busy = ref(false),
|
||
error = ref(""),
|
||
success = ref("");
|
||
const modal = ref<"create" | "reset" | "delete" | null>(null),
|
||
selected = ref<Account | null>(null),
|
||
username = ref(""),
|
||
password = ref("");
|
||
const apiError = useApiError();
|
||
async function load() {
|
||
try {
|
||
[accounts.value, sessions.value] = await Promise.all([
|
||
$fetch<Account[]>("/api/accounts"),
|
||
$fetch<Session[]>("/api/sessions"),
|
||
]);
|
||
} catch (e) {
|
||
error.value = apiError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
onMounted(load);
|
||
function open(
|
||
action: "create" | "reset" | "delete",
|
||
account: Account | null = null,
|
||
) {
|
||
modal.value = action;
|
||
selected.value = account;
|
||
username.value = "";
|
||
password.value = "";
|
||
error.value = "";
|
||
success.value = "";
|
||
}
|
||
function date(value: number | null) {
|
||
return value
|
||
? new Intl.DateTimeFormat(locale.value, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(value * 1000)
|
||
: t("accounts.never");
|
||
}
|
||
async function submit() {
|
||
busy.value = true;
|
||
error.value = "";
|
||
try {
|
||
if (modal.value === "create")
|
||
await $fetch("/api/accounts", {
|
||
method: "POST",
|
||
body: { username: username.value, password: password.value },
|
||
});
|
||
if (modal.value === "reset")
|
||
await $fetch(`/api/accounts/${selected.value!.id}/password`, {
|
||
method: "POST",
|
||
body: { password: password.value },
|
||
});
|
||
if (modal.value === "delete")
|
||
await $fetch(`/api/accounts/${selected.value!.id}`, { method: "DELETE" });
|
||
if (modal.value === "reset" && selected.value?.id === current.user?.id) {
|
||
modal.value = null;
|
||
await current.refresh();
|
||
await navigateTo("/login");
|
||
return;
|
||
}
|
||
modal.value = null;
|
||
success.value = t("accounts.saved");
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e);
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
async function revoke(id: string) {
|
||
busy.value = true;
|
||
error.value = "";
|
||
try {
|
||
await $fetch(`/api/sessions/${id}`, { method: "DELETE" });
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e);
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
<template>
|
||
<div class="page settings-page">
|
||
<div class="page-heading">
|
||
<div>
|
||
<div class="eyebrow">{{ $t("accounts.eyebrow") }}</div>
|
||
<h1>{{ $t("accounts.title") }}<span class="heading-dot">.</span></h1>
|
||
<p class="muted">{{ $t("accounts.subtitle") }}</p>
|
||
</div>
|
||
<GlassButton variant="primary" @click="open('create')"
|
||
><Plus :size="18" />{{ $t("accounts.add") }}</GlassButton
|
||
>
|
||
</div>
|
||
<p v-if="error && !modal" role="alert" class="error">{{ error }}</p>
|
||
<p v-if="success" role="status" class="success">{{ success }}</p>
|
||
<div class="security-banner">
|
||
<ShieldCheck :size="23" />
|
||
<div>
|
||
<strong>{{ $t("accounts.protected") }}</strong>
|
||
<p>{{ $t("accounts.protectedHint") }}</p>
|
||
</div>
|
||
<span class="status-dot live" />
|
||
</div>
|
||
<section class="content-panel">
|
||
<div class="section-heading">
|
||
<h2><UsersRound :size="20" />{{ $t("accounts.localAccounts") }}</h2>
|
||
<span class="count-badge">{{ accounts.length }}</span>
|
||
</div>
|
||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||
<div v-else class="table-scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>{{ $t("accounts.account") }}</th>
|
||
<th>{{ $t("accounts.role") }}</th>
|
||
<th>{{ $t("accounts.lastLogin") }}</th>
|
||
<th>{{ $t("accounts.sessions") }}</th>
|
||
<th>
|
||
<span class="sr-only">{{ $t("accounts.actions") }}</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="account in accounts" :key="account.id">
|
||
<td>
|
||
<span class="account-name"
|
||
><span class="avatar">{{
|
||
account.username[0]?.toUpperCase()
|
||
}}</span
|
||
>{{ account.username
|
||
}}<small v-if="account.id === current.user?.id">{{
|
||
$t("accounts.you")
|
||
}}</small></span
|
||
>
|
||
</td>
|
||
<td>
|
||
<span class="role-tag" :class="account.role">{{
|
||
$t(`accounts.${account.role}`)
|
||
}}</span>
|
||
</td>
|
||
<td class="muted">{{ date(account.last_login) }}</td>
|
||
<td>{{ account.active_sessions }}</td>
|
||
<td>
|
||
<div class="row-actions">
|
||
<GlassButton
|
||
class="icon-button"
|
||
:aria-label="`${$t('accounts.reset')} — ${account.username}`"
|
||
@click="open('reset', account)"
|
||
><KeyRound :size="17" /></GlassButton
|
||
><GlassButton
|
||
v-if="account.role !== 'admin'"
|
||
class="icon-button"
|
||
:aria-label="`${$t('accounts.delete')} — ${account.username}`"
|
||
@click="open('delete', account)"
|
||
><Trash2 :size="17"
|
||
/></GlassButton>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section class="content-panel">
|
||
<div class="section-heading">
|
||
<h2>{{ $t("accounts.activeSessions") }}</h2>
|
||
<GlassButton class="small-button" @click="load">{{
|
||
$t("common.refresh")
|
||
}}</GlassButton>
|
||
</div>
|
||
<p class="muted">{{ $t("accounts.sessionHint") }}</p>
|
||
<div class="session-row" v-for="session in sessions" :key="session.id">
|
||
<span class="avatar">{{ session.username[0]?.toUpperCase() }}</span>
|
||
<div>
|
||
<strong
|
||
>{{ session.username }}
|
||
<small v-if="session.id === current.user?.session_id">{{
|
||
$t("accounts.current")
|
||
}}</small></strong
|
||
><small
|
||
>{{ $t("accounts.lastActive") }} ·
|
||
{{ date(session.last_seen) }}</small
|
||
>
|
||
</div>
|
||
<GlassButton
|
||
v-if="session.id !== current.user?.session_id"
|
||
class="small-button"
|
||
:disabled="busy"
|
||
@click="revoke(session.id)"
|
||
>{{ $t("accounts.revoke") }}</GlassButton
|
||
>
|
||
</div>
|
||
</section>
|
||
<GlassModal
|
||
:open="!!modal"
|
||
:title="
|
||
$t(
|
||
modal === 'create'
|
||
? 'accounts.add'
|
||
: modal === 'reset'
|
||
? 'accounts.reset'
|
||
: 'accounts.delete',
|
||
)
|
||
"
|
||
@close="!busy && (modal = null)"
|
||
><form @submit.prevent="submit">
|
||
<template v-if="modal === 'create'"
|
||
><label
|
||
>{{ $t("accounts.username")
|
||
}}<input
|
||
v-model="username"
|
||
required
|
||
minlength="3"
|
||
maxlength="40"
|
||
pattern="[A-Za-z0-9_.\-]+"
|
||
autocomplete="off"
|
||
/></label>
|
||
<p class="field-help">{{ $t("accounts.usernameHint") }}</p></template
|
||
><template v-if="modal !== 'delete'"
|
||
><label
|
||
>{{ $t("accounts.password")
|
||
}}<input
|
||
v-model="password"
|
||
type="password"
|
||
required
|
||
minlength="12"
|
||
maxlength="256"
|
||
pattern="(?=.*[A-Za-zА-Яа-яЁёÀ-ÿ])(?=.*[0-9]).{12,}"
|
||
autocomplete="new-password"
|
||
/></label>
|
||
<p class="field-help">{{ $t("accounts.passwordHint") }}</p>
|
||
<p v-if="modal === 'reset'" class="muted">
|
||
{{ $t("accounts.resetHint", { name: selected?.username }) }}
|
||
</p></template
|
||
>
|
||
<p v-else>
|
||
{{ $t("accounts.deleteHint", { name: selected?.username }) }}
|
||
</p>
|
||
<p v-if="error" role="alert" class="error">{{ error }}</p>
|
||
<div class="modal-actions">
|
||
<GlassButton :disabled="busy" @click="modal = null">{{
|
||
$t("common.cancel")
|
||
}}</GlassButton
|
||
><GlassButton
|
||
:variant="modal === 'delete' ? 'danger' : 'primary'"
|
||
type="submit"
|
||
:disabled="busy"
|
||
>{{
|
||
$t(
|
||
busy
|
||
? "common.working"
|
||
: modal === "delete"
|
||
? "accounts.delete"
|
||
: "common.save",
|
||
)
|
||
}}</GlassButton
|
||
>
|
||
</div>
|
||
</form></GlassModal
|
||
>
|
||
</div>
|
||
</template>
|