Files
Webka/frontend/pages/settings/accounts.vue
2026-09-06 16:23:09 +05:00

293 lines
8.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>