feat: improve account security
Support TOTP 2fa, allow for changing password, add session management and account removal.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user