DUNGEONS-34: implement Webka local streaming app

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 16:23:09 +05:00
parent 5540b9a1f4
commit 7c737c1011
36 changed files with 21596 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
<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>