Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ change them. The flip side of that build-time capture is a deployment contract:
those policy variables, rebuild the app, or the auth pages will keep advertising the old
capabilities (the server still enforces its own policy either way).

Every account can enable TOTP two-factor authentication from the signed-in user menu. Setup at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High README.md:194

OAuth-only accounts cannot enable TOTP, so the statement that Every account can enable it is incorrect. With allowPasswordless disabled, /two-factor/enable requires validatePassword, which returns false when no password credential exists; either support enrollment for OAuth-only users or qualify this documentation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @README.md around line 194:

OAuth-only accounts cannot enable TOTP, so the statement that `Every account can enable` it is incorrect. With `allowPasswordless` disabled, `/two-factor/enable` requires `validatePassword`, which returns false when no password credential exists; either support enrollment for OAuth-only users or qualify this documentation.

`/two-factor` requires the account password, displays a QR code plus one-time backup codes, and
does not become active until the first authenticator code verifies. The same route handles the
second-factor challenge after password sign-in, including backup-code recovery and an optional
30-day trusted-device cookie. Apply the checked-in database migrations before deploying this
feature: the Better Auth plugin adds `user.two_factor_enabled` and the `two_factor` table.

The interface ships English and Italian through `@nuxtjs/i18n`, with dictionaries split by scope in
`packages/i18n/locales/<locale>/` and shared with the marketing site; each app loads only the
scopes it renders. `aube run i18n:status` builds a
Expand Down
6 changes: 6 additions & 0 deletions apps/dashboard/app/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
lastLoginMethodClient,
multiSessionClient,
organizationClient,
twoFactorClient,
} from 'better-auth/client/plugins';

// Better Auth is mounted in this app's own server (`server/auth.config.ts`), so every request is
Expand Down Expand Up @@ -39,6 +40,11 @@ export default defineClientAuth(() => {
betterEnrollmentClient(),
lastLoginMethodClient(),
multiSessionClient(),
twoFactorClient({
onTwoFactorRedirect: async () => {
await navigateTo('/two-factor');
},
}),
deviceAuthorizationClient(),
dashClient(),
// The one entry that is gated, and the only one whose absence changes nothing about the
Expand Down
273 changes: 273 additions & 0 deletions apps/dashboard/app/pages/two-factor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
<template>
<section class="panel w-full max-w-120 p-6">
<h1 class="m-0 text-lg font-650 tracking-tight">{{ $t('auth.twoFactor.title') }}</h1>

<p v-if="!ready" class="mb-0 mt-4 text-xs text-muted">
{{ $t('auth.twoFactor.loading') }}
</p>

<template v-else-if="!loggedIn">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.challengeSubtitle') }}</p>

<div class="mt-5 grid grid-cols-2 gap-2" role="group">
<button
v-for="factor in factors"
:key="factor"
class="focus-ring h-9 border text-xs font-650 transition"
:class="
challengeFactor === factor
? 'border-accent bg-accent/8 text-ink'
: 'border-line bg-raised text-muted hover:text-ink'
"
:data-factor="factor"
type="button"
@click="challengeFactor = factor"
>
{{
factor === 'totp'
? $t('auth.twoFactor.factor.totp')
: $t('auth.twoFactor.factor.backup')
}}
</button>
</div>

<form data-mode="challenge" class="mt-4 flex flex-col gap-3" @submit.prevent="onChallenge">
<label class="flex flex-col gap-1.5">
<span class="label-upper">
{{
challengeFactor === 'totp'
? $t('auth.twoFactor.code')
: $t('auth.twoFactor.backupCode')
}}
</span>
<input
v-model="code"
autocomplete="one-time-code"
class="input-field mono"
:inputmode="challengeFactor === 'totp' ? 'numeric' : 'text'"
required
spellcheck="false"
type="text"
/>
</label>

<label class="flex items-center gap-2 text-xs text-muted">
<input v-model="trustDevice" type="checkbox" />
{{ $t('auth.twoFactor.trustDevice') }}
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.verifyPending') : $t('auth.twoFactor.verify') }}
</button>
</form>
</template>

<template v-else-if="twoFactorEnabled">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.enabled') }}</p>

<form data-mode="disable" class="mt-5 flex flex-col gap-3" @submit.prevent="onDisable">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.login.password') }}</span>
<input
v-model="password"
autocomplete="current-password"
class="input-field"
required
type="password"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-subtle" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.disablePending') : $t('auth.twoFactor.disable') }}
</button>
</form>
</template>

<template v-else-if="totpUri">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.scan') }}</p>

<div class="mt-5 grid gap-5 md:grid-cols-[12rem_1fr]">
<div class="grid place-items-center border border-line bg-white p-3">
<img v-if="qrImage" class="h-44 w-44" :src="qrImage" :alt="$t('auth.twoFactor.qrAlt')" />
<code v-else class="break-all text-3xs text-canvas">{{ totpUri }}</code>
</div>

<div>
<h2 class="m-0 text-sm font-650">{{ $t('auth.twoFactor.backupCodesTitle') }}</h2>
<p class="mb-3 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.backupCodesHint') }}</p>
<ul class="m-0 grid grid-cols-2 gap-1 border border-line bg-raised p-3 list-none">
<li v-for="backupCode in backupCodes" :key="backupCode" class="mono text-xs">
{{ backupCode }}
</li>
</ul>
</div>
</div>

<form data-mode="confirm" class="mt-5 flex flex-col gap-3" @submit.prevent="onConfirm">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.twoFactor.confirmCode') }}</span>
<input
v-model="code"
autocomplete="one-time-code"
class="input-field mono"
inputmode="numeric"
required
spellcheck="false"
type="text"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.confirmPending') : $t('auth.twoFactor.confirm') }}
</button>
</form>
</template>

<template v-else>
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.disabled') }}</p>

<form data-mode="enable" class="mt-5 flex flex-col gap-3" @submit.prevent="onEnable">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.login.password') }}</span>
<input
v-model="password"
autocomplete="current-password"
class="input-field"
required
type="password"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.enablePending') : $t('auth.twoFactor.enable') }}
</button>
</form>
</template>
</section>
</template>

<script setup lang="ts">
import QRCode from 'qrcode';
import { useI18n } from 'vue-i18n';

definePageMeta({ layout: 'auth' });

const { user, loggedIn, ready, fetchSession } = useUserSession();
const i18n = useI18n();

const factors = ['totp', 'backup'] as const;
type ChallengeFactor = (typeof factors)[number];

const challengeFactor = ref<ChallengeFactor>('totp');
const trustDevice = ref(true);
const password = ref('');
const code = ref('');
const totpUri = ref('');
const qrImage = ref('');
const backupCodes = ref<string[]>([]);
const isPending = ref(false);
const errorMessage = ref<string>();

const twoFactorEnabled = computed(() =>
Boolean(user.value && 'twoFactorEnabled' in user.value && user.value.twoFactorEnabled),
);

function authClient() {
const client = useAuthClient();
if (!client) throw new Error('auth client unavailable');
return client;
}

function responseFailed(result: { error?: unknown }): boolean {
if (!result.error) return false;
errorMessage.value = i18n.t('auth.twoFactor.error');
return true;
}

async function onChallenge(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const value = code.value.trim();
const result =
challengeFactor.value === 'totp'
? await authClient().twoFactor.verifyTotp({ code: value, trustDevice: trustDevice.value })
: await authClient().twoFactor.verifyBackupCode({
code: value,
trustDevice: trustDevice.value,
});
if (!responseFailed(result)) await navigateTo('/');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Two-factor completion drops invitation target

A user who signs in from an invitation and completes a TOTP challenge is always sent to /. The invitation URL and its redemption token are not retained when entering /two-factor, so successful verification does not return the user to the invitation flow.

Artifacts

Source capture showing the two-factor redirect loses the return query

  • Executed Node source inspection records the unconditional root navigation at line 208 and the redirect callback that sends the user only to `/two-factor`, showing no return-query handling. The takeaway is that the return target is not represented in this handoff.

Direct runtime harness for successful two-factor verification with an invitation target

  • Review-authored Node harness reads and executes the exact extracted production challenge-handler body with a successful TOTP response and an invitation redirect target. The takeaway is that the tested handler is tied directly to the current source.

Successful two-factor verification navigates to root instead of invitation

  • Executed harness output shows a successful TOTP request from `/two-factor?redirect=%2Finvite%3Ftoken%3Dinvitation-token`, followed by `navigateTo target: /` and `return target preserved: false`. The takeaway is that the proposed failure path is confirmed.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/app/pages/two-factor.vue
Line: 208

Comment:
**Two-factor completion drops invitation target**

A user who signs in from an invitation and completes a TOTP challenge is always sent to `/`. The invitation URL and its redemption token are not retained when entering `/two-factor`, so successful verification does not return the user to the invitation flow.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor Fix in Cursor Cloud Agents

} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onEnable(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.enable({ password: password.value });
if (responseFailed(result) || !result.data) return;

totpUri.value = result.data.totpURI;
backupCodes.value = result.data.backupCodes;
password.value = '';
qrImage.value = await QRCode.toDataURL(result.data.totpURI, {
errorCorrectionLevel: 'M',
margin: 1,
width: 352,
});
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onConfirm(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.verifyTotp({ code: code.value.trim() });
if (responseFailed(result)) return;
await fetchSession({ force: true });
totpUri.value = '';
qrImage.value = '';
backupCodes.value = [];
code.value = '';
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onDisable(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.disable({ password: password.value });
if (responseFailed(result)) return;
password.value = '';
await fetchSession({ force: true });
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}
</script>
13 changes: 13 additions & 0 deletions apps/dashboard/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,16 @@ export const viteHubVercelEntryName = '__server.func';
export function viteHubVercelEntryAlias(serverDirectory: string): string {
return join(dirname(serverDirectory), viteHubVercelEntryName);
}

// Aube can leave this optional peer link inside Better Auth's adapter package pointing at a
// virtual-store entry it did not materialize. Nitro's node-file trace records the dangling path,
// then fails while canonicalising every recorded reason even though `drizzle-orm` is also traced
// through the database package's valid dependency. Ignore only that nested link: the valid package
// and every other auth dependency remain external and are still copied into the server output.
const brokenAuthDrizzlePeerLink =
/(?:^|\/)\.aube\/@better-auth\+drizzle-adapter@[^/]+\/node_modules\/drizzle-orm(?:\/|$)/u;

/** Returns whether Nitro's file trace should skip Aube's dangling optional peer link. */
export function shouldIgnoreBrokenAuthPeerLink(path: string): boolean {
return brokenAuthDrizzlePeerLink.test(path.replaceAll('\\', '/'));
}
13 changes: 13 additions & 0 deletions apps/dashboard/modules/auth/components/TwoFactorError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<p
v-if="message"
class="m-0 border border-danger/35 bg-danger/8 p-2.5 text-xs text-danger"
role="alert"
>
{{ message }}
</p>
</template>

<script setup lang="ts">
defineProps<{ message?: string }>();
</script>
3 changes: 3 additions & 0 deletions apps/dashboard/modules/auth/components/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<p class="m-0 mt-1 truncate text-xs font-650" :title="user?.email">
{{ user?.name || user?.email }}
</p>
<NuxtLink class="btn-link mt-2.5 block text-xs" to="/two-factor">
{{ $t('auth.twoFactor.link') }}
</NuxtLink>
<button
class="focus-ring mt-2.5 h-8 w-full border border-line bg-raised text-xs text-ink font-650 transition hover:border-muted"
type="button"
Expand Down
14 changes: 13 additions & 1 deletion apps/dashboard/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { authConfigFromEnvironment, infraFromEnvironment } from '@agent-zero/aut
import { defaultLocale, i18nLocalesFor, localeCookieName } from '@agent-zero/i18n';
import { defineNuxtConfig } from 'nuxt/config';

import { viteHubPresetFromEnvironment, viteHubVercelEntryAlias } from './config/env.js';
import {
shouldIgnoreBrokenAuthPeerLink,
viteHubPresetFromEnvironment,
viteHubVercelEntryAlias,
} from './config/env.js';

// Resolved once at config evaluation so the dashboard's auth pages publish the same sign-in
// policy `server/auth.config.ts` enforces at runtime (AUTH_ENABLE_SIGNUP, GitHub OAuth
Expand Down Expand Up @@ -191,6 +195,11 @@ export default defineNuxtConfig({
},

nitro: {
externals: {
traceOptions: {
ignore: shouldIgnoreBrokenAuthPeerLink,
},
},
// Registered as a Nitro module rather than through `nitro.hooks`: a handler under that key
// replaces the preset's own handler for the same hook, and the `vercel` preset writes
// `config.json` and each function's `.vc-config.json` from its `compiled` hook — losing it
Expand Down Expand Up @@ -239,6 +248,9 @@ export default defineNuxtConfig({
// than being shown the form. The auth layout, not the shell, because the visitor arriving here
// typed a code off a terminal and has no business in the navigation.
'/device': { auth: { only: 'user' } },
// Serves both authenticated enrollment and the pre-session challenge after password sign-in.
// The Better Auth endpoints enforce the relevant cookie/session for each operation.
'/two-factor': { auth: false },
// Reached from an invitation email, so the visitor is frequently signed out at that moment:
// requiring a session sends them through /login and back, rather than rejecting the link.
'/organizations/accept-invitation/**': { appLayout: 'default', auth: { only: 'user' } },
Expand Down
Loading
Loading