feat: improve account security
Support TOTP 2fa, allow for changing password, add session management and account removal.
This commit is contained in:
+162
-5
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import CodeInput from '~/components/CodeInput.vue';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
const { client: authClient, signIn, session } = useAuth();
|
||||
const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
@@ -21,6 +22,27 @@ const form = reactive({
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
// second step, only shown for accounts with two-factor authentication
|
||||
const step = ref<'credentials' | 'twoFactor'>('credentials');
|
||||
const twoFactor = reactive({
|
||||
code: '',
|
||||
useBackupCode: false,
|
||||
trustDevice: false,
|
||||
error: '',
|
||||
});
|
||||
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
|
||||
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const focusCodeInput = async () => {
|
||||
await nextTick();
|
||||
|
||||
if (twoFactor.useBackupCode) {
|
||||
backupCodeInputEl.value?.focus();
|
||||
} else {
|
||||
codeInputEl.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -97,11 +119,94 @@ const submit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
if (res.data.twoFactorRequired) {
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
twoFactor.useBackupCode = false;
|
||||
step.value = 'twoFactor';
|
||||
|
||||
await focusCodeInput();
|
||||
return;
|
||||
}
|
||||
|
||||
return finishSignIn(res.data.user.id);
|
||||
};
|
||||
|
||||
// The password is still in memory at this point, which is the only time we
|
||||
// can derive the key that encrypts the user's API keys.
|
||||
const finishSignIn = async (userId: string) => {
|
||||
const key = await deriveKey(form.password, userId);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
|
||||
const backToCredentials = (error = '') => {
|
||||
step.value = 'credentials';
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
|
||||
if (error) {
|
||||
passwordInputEl.value!.setCustomValidity(error);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBackupCode = async () => {
|
||||
twoFactor.useBackupCode = !twoFactor.useBackupCode;
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
|
||||
await focusCodeInput();
|
||||
};
|
||||
|
||||
const submitTwoFactor = async () => {
|
||||
const code = twoFactor.code.trim();
|
||||
if (code === '' || loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
twoFactor.error = '';
|
||||
|
||||
const res = await verifyTwoFactor(code, {
|
||||
backupCode: twoFactor.useBackupCode,
|
||||
trustDevice: twoFactor.trustDevice,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
loading.value = false;
|
||||
|
||||
if (res.error.data?.status === 429) {
|
||||
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
switch (res.error.data?.code) {
|
||||
case 'INVALID_CODE':
|
||||
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
|
||||
break;
|
||||
case 'INVALID_BACKUP_CODE':
|
||||
twoFactor.error = 'That backup code is not valid, or it was already used.';
|
||||
break;
|
||||
case 'ACCOUNT_TEMPORARILY_LOCKED':
|
||||
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
|
||||
break;
|
||||
case 'INVALID_TWO_FACTOR_COOKIE':
|
||||
// the 10 minute window to enter a code ran out
|
||||
backToCredentials('Sign in timed out. Enter your password again.');
|
||||
break;
|
||||
default:
|
||||
twoFactor.error = 'Something went wrong. Try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
twoFactor.code = '';
|
||||
await focusCodeInput();
|
||||
return;
|
||||
}
|
||||
|
||||
await finishSignIn(res.data.user.id);
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,11 +222,17 @@ const submit = async () => {
|
||||
<div class="z-10 max-w-sm w-full">
|
||||
<div class="flex flex-col items-center mb-4 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Sign in to your account</p>
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
{{ step === 'credentials'
|
||||
? 'Sign in to your account'
|
||||
: twoFactor.useBackupCode
|
||||
? 'Enter one of your backup codes'
|
||||
: 'Enter the code from your authenticator app' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="email"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
|
||||
@@ -144,9 +255,55 @@ const submit = async () => {
|
||||
<span v-else>Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
|
||||
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
|
||||
<label for="two-factor-code"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||
Backup code
|
||||
</label>
|
||||
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
|
||||
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
|
||||
placeholder="xxxxx-xxxxx"
|
||||
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
<fieldset v-else>
|
||||
<legend
|
||||
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
6-digit code
|
||||
</legend>
|
||||
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
|
||||
@complete="submitTwoFactor" />
|
||||
</fieldset>
|
||||
|
||||
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
|
||||
{{ twoFactor.error }}
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
|
||||
<input type="checkbox" v-model="twoFactor.trustDevice"
|
||||
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
|
||||
<span>Trust this browser for 30 days</span>
|
||||
</label>
|
||||
|
||||
<button class="accent w-full h-11 flex items-center justify-center font-semibold" type="submit">
|
||||
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<button type="button" @click="toggleBackupCode"
|
||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
||||
</button>
|
||||
<button type="button" @click="backToCredentials()" class="@hover:underline">
|
||||
Back to log in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||
<p v-if="!config.public.disableSignup && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||
New to Veridian?
|
||||
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
|
||||
@@ -5,6 +5,7 @@ const route = useRoute();
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
security: 'Security',
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
providers: 'AI Providers',
|
||||
|
||||
@@ -20,7 +20,7 @@ const updateHinting = (e: Event) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-6 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
class="flex flex-col gap-6 -mx-4 p-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<h4 class="font-medium">Theme</h4>
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const AVATAR_SIZE = 256;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
const { user, updateProfile } = useAuth();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const avatarBusy = ref(false);
|
||||
@@ -224,7 +226,8 @@ const memberSince = computed(() => {
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Email</span>
|
||||
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
||||
<span
|
||||
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
||||
{{ user?.email }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -235,6 +238,20 @@ const memberSince = computed(() => {
|
||||
{{ memberSince }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||
<h2 class="text-[15px] font-semibold">Delete account</h2>
|
||||
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
|
||||
Permanently deletes your agents, topics, and api keys from this instance.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
|
||||
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
|
||||
Delete account
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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