4bba529ba8
Support TOTP 2fa, allow for changing password, add session management and account removal.
284 lines
12 KiB
Vue
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>
|