Files
veridian/app/pages/settings/security.vue
T
zoeissleeping 4bba529ba8 feat: improve account security
Support TOTP 2fa, allow for changing password, add session management
and account removal.
2026-10-08 12:43:22 -05:00

284 lines
12 KiB
Vue

<script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
const PRIMARY_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200';
const OUTLINE_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:text-[var(--text-tertiary)] disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
const { client: authClient, user } = useAuth();
const { providers } = await useModels();
// tinted pill used for the states that are on, same look as the role badge
const ACCENT_PILL_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
};
// fixed locale and time zone so the server and client render the same text
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
// The server does not know the viewer's time zone, so it renders the date only
// and the time of day is filled in once the page is running in the browser.
const mounted = ref(false);
onMounted(() => {
mounted.value = true;
});
const formatDateTime = (date: string | Date) => {
if (!mounted.value) return formatDate(date);
return new Date(date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
// ---------------------------------------------------------------------------
// Password
// ---------------------------------------------------------------------------
const { openDialog } = useDialog();
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
const { data: passwordInfo, refresh: refreshPasswordInfo } = await useFetch<{ changedAt: string | null }>('/api/account/password', {
key: 'account_password',
});
const openChangePassword = () => {
openDialog(DialogType.ChangePassword, async (result) => {
if (result.ok === false) return;
await Promise.all([refreshPasswordInfo(), loadSessions()]);
});
};
// ---------------------------------------------------------------------------
// Two-factor authentication
// ---------------------------------------------------------------------------
const twoFactorEnabled = computed(() => !!user.value?.twoFactorEnabled);
const openTwoFactor = (action: 'enable' | 'disable' | 'regenerate') => {
// Turning two-factor on or off swaps the session for a new one, and the
// dialog can be dismissed after that happened, so always reload.
openDialog(DialogType.TwoFactor, async () => {
await loadSessions();
}, { action });
};
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
type SessionRow = {
id: string;
createdAt: string;
updatedAt: string;
ipAddress: string | null;
userAgent: string | null;
current: boolean;
};
const sessionsError = ref('');
const sessionsBusy = ref(false);
// Better Auth only lists sessions for a device that signed in within the last
// day, so this goes through our own endpoint instead.
const { data: sessionData, error: sessionsLoadError, refresh: loadSessions } = await useFetch<SessionRow[]>('/api/account/sessions', {
key: 'account_sessions',
});
const sessions = computed(() => sessionData.value ?? []);
// this device first, then most recently active
const sortedSessions = computed(() => [...sessions.value].sort((a, b) => {
if (a.current) return -1;
if (b.current) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}));
watch(sessionsLoadError, (error) => {
sessionsError.value = error ? 'Your sessions could not be loaded.' : '';
}, { immediate: true });
const revokeSession = async (id: string) => {
if (sessionsBusy.value) return;
sessionsBusy.value = true;
sessionsError.value = '';
try {
await $fetch(`/api/account/sessions/${id}`, { method: 'DELETE' });
await loadSessions();
} catch (error) {
console.error('Failed to revoke session:', error);
sessionsError.value = 'That session could not be signed out. Try again.';
}
sessionsBusy.value = false;
};
const revokeOtherSessions = async () => {
if (sessionsBusy.value) return;
sessionsBusy.value = true;
sessionsError.value = '';
const { error } = await authClient.revokeOtherSessions();
if (error) {
sessionsError.value = 'Your other sessions could not be signed out. Try again.';
} else {
await loadSessions();
}
sessionsBusy.value = false;
};
const describeDevice = (userAgent: string | null | undefined) => {
const ua = userAgent ?? '';
const browser =
/Edg\//.test(ua) ? 'Edge'
: /OPR\//.test(ua) ? 'Opera'
: /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: 'Unknown browser';
const os =
/Android/.test(ua) ? 'Android'
: /iPhone|iPad|iPod/.test(ua) ? 'iOS'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X/.test(ua) ? 'macOS'
: /Linux/.test(ua) ? 'Linux'
: '';
return {
label: os ? `${browser} on ${os}` : browser,
mobile: /Android|iPhone|iPad|iPod|Mobile/.test(ua),
};
};
</script>
<template>
<div
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-7 w-full max-w-3xl">
<!-- PASSWORD -->
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium">Password</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
{{ passwordInfo?.changedAt
? `Last changed ${formatDate(passwordInfo.changedAt)}`
: 'Never changed' }}
</p>
</div>
<button type="button" :class="BUTTON_CLASS" @click="openChangePassword">
Change password…
</button>
</div>
</section>
<!-- TWO-FACTOR -->
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium flex items-center gap-2">
Two-factor sign-in
<span v-if="twoFactorEnabled"
class="text-xs font-normal px-2 py-0.25 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">On</span>
<span v-else
class="text-xs font-normal px-2 py-0.25 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">Off</span>
</h2>
</div>
<button v-if="twoFactorEnabled" type="button" :class="OUTLINE_BUTTON_CLASS"
@click="openTwoFactor('disable')">
Turn off
</button>
<button v-else type="button" :class="[PRIMARY_BUTTON_CLASS, 'font-semibold']"
@click="openTwoFactor('enable')">
Set up
</button>
</div>
<div v-if="twoFactorEnabled" class="flex flex-wrap items-center justify-between gap-3">
<span class="flex-1 min-w-60 text-sm">
Backup codes
<span class="text-[13px]" :class="twoFactorEnabled ? 'text-[var(--text-secondary)]' : ''">
· {{ twoFactorEnabled
? 'each one signs you in once if you lose your authenticator app'
: 'available once two-factor is on' }}
</span>
</span>
<button type="button" :disabled="!twoFactorEnabled" :class="OUTLINE_BUTTON_CLASS"
@click="openTwoFactor('regenerate')">
Regenerate
</button>
</div>
</section>
<!-- SESSIONS -->
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium">Sessions</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
Devices signed in to your account.
</p>
</div>
<button type="button" :disabled="sessionsBusy || sessions.length < 2" :class="OUTLINE_BUTTON_CLASS"
@click="revokeOtherSessions">
Sign out everywhere else
</button>
</div>
<p v-if="sessionsError" role="alert" class="text-sm text-red-400">{{ sessionsError }}</p>
<ul class="flex flex-col gap-1">
<li v-for="s in sortedSessions" :key="s.id"
class="flex flex-wrap items-center gap-3 min-h-13 px-3 py-2 rounded-lg bg-[var(--bg-container)]">
<span class="text-5 flex-shrink-0 text-[var(--text-secondary)]"
:class="describeDevice(s.userAgent).mobile ? 'i-tabler-device-mobile' : 'i-tabler-device-desktop'"></span>
<div class="flex flex-col flex-1 min-w-40">
<span class="text-sm font-medium">
{{ describeDevice(s.userAgent).label }}
<span v-if="s.ipAddress" class="font-normal text-[var(--text-tertiary)]">
· {{ s.ipAddress }}
</span>
</span>
<span class="text-xs text-[var(--text-secondary)]">
{{ s.current ? 'Active now' : `Last active ${formatDateTime(s.updatedAt)}` }}
</span>
</div>
<span v-if="s.current" class="text-xs px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">
This device
</span>
<button v-else type="button" :disabled="sessionsBusy"
class="flex items-center h-7.5 px-2.5 text-[13px] rounded-md border border-[var(--color-border)] text-[var(--text-primary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
@click="revokeSession(s.id)">
Revoke
</button>
</li>
</ul>
</section>
</div>
</div>
</template>