Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
name: CI

on:
# Every pull request, not just the ones targeting main: stacked PRs (a chain
# of branches merged bottom-up at the end) target their parent branch, and
# with a `branches: [main]` filter they would ride all the way to the final
# merge without a single CI run.
pull_request:
branches: [main]
push:
branches: [main]

Expand Down
42 changes: 42 additions & 0 deletions app/i18n.sync.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,48 @@
"template_image": "140d5d5895d7",
"template_name": "f95e97c2f767",
"templates": "56b564b75c7f"
},
"user/auth:ru": {
"account.closed_body": "0be53513ddac",
"account.closed_cta": "27a0d553bf29",
"account.closed_title": "dd5e053a970d",
"account.create_account": "86033f75a4c0",
"account.create_subtitle": "c5aff35f38e9",
"account.create_title": "9e709348f582",
"account.email_invalid": "c6e81fdf78b3",
"account.email_label": "969ccbd3cf63",
"account.email_placeholder": "9a17bdc59659",
"account.email_required": "aae929110124",
"account.have_account": "e77fea936d3e",
"account.login_failed": "5a96e8452f1e",
"account.name_label": "dcd1d5223f73",
"account.name_placeholder": "01332c876518",
"account.name_required": "604fd1b2e29c",
"account.need_access": "ab1a55b13fa2",
"account.new_here": "38c1c4457cd1",
"account.password_hide": "a60a56c584b3",
"account.password_hint": "e4e54a0028c8",
"account.password_label": "e7cf3ef4f17c",
"account.password_required": "7aeae1412484",
"account.password_show": "6aeaa6a53d09",
"account.password_too_short": "977f3b2676a9",
"account.register_failed": "d40e3b08ef50",
"account.sign_in": "bfd402b2f6f3",
"account.submit_login": "bfd402b2f6f3",
"account.submit_login_pending": "8b9d3de8ae00",
"account.submit_register": "798ca2ce18bf",
"account.submit_register_pending": "f2daf61e527b",
"account.welcome_subtitle": "c2eae8162dd8",
"account.welcome_title": "6621249514b7",
"promo.back_to_landing": "a0653cf86b3c",
"promo.badge": "de065a5c850b",
"promo.feature_deploy": "b8d686e626c7",
"promo.feature_state": "dcb893a52450",
"promo.feature_streaming": "eb803528b70d",
"promo.footer": "8f3bab563458",
"promo.lede": "0877d8f80ff2",
"promo.title": "9f28248b81cc",
"promo.title_accent": "09659d3fbc68"
}
}
}
91 changes: 53 additions & 38 deletions app/slices/user/auth/components/auth/common/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ type Mode = 'login' | 'register';
const props = defineProps<{
mode: Mode;
submitting?: boolean;
/** Human text from the API — already English, not a key. Null when the call
* failed without a usable message, in which case `failed` drives our copy. */
errorMessage?: string | null;
failed?: boolean;
registrationEnabled?: boolean;
}>();

Expand All @@ -13,6 +16,9 @@ const emit = defineEmits<{
}>();

const form = reactive({ name: '', email: '', password: '' });
// Validation state holds i18n KEYS, not sentences: the template renders them
// with $t. Keeping the text here would hide user-visible copy inside branching
// logic, where the next extraction pass would never find it.
const errors = reactive<
Partial<Record<'name' | 'email' | 'password', string>>
>({});
Expand All @@ -24,21 +30,23 @@ const showPassword = ref(false);
function validateField(field: 'name' | 'email' | 'password') {
if (field === 'name') {
errors.name =
props.mode === 'register' && !form.name.trim() ? 'Name is required' : undefined;
props.mode === 'register' && !form.name.trim()
? 'account.name_required'
: undefined;
return;
}
if (field === 'email') {
const email = form.email.trim();
if (!email) errors.email = 'Email is required';
if (!email) errors.email = 'account.email_required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.email = 'Enter a valid email';
errors.email = 'account.email_invalid';
else errors.email = undefined;
return;
}
if (field === 'password') {
if (!form.password) errors.password = 'Password is required';
if (!form.password) errors.password = 'account.password_required';
else if (props.mode === 'register' && form.password.length < 8)
errors.password = 'At least 8 characters';
errors.password = 'account.password_too_short';
else errors.password = undefined;
}
}
Expand All @@ -65,24 +73,29 @@ function onSubmit() {
});
}

const title = computed(() =>
props.mode === 'login' ? 'Welcome back' : 'Create your account',
const titleKey = computed(() =>
props.mode === 'login' ? 'account.welcome_title' : 'account.create_title',
);

const subtitle = computed(() =>
const subtitleKey = computed(() =>
props.mode === 'login'
? 'Sign in to manage your agents.'
: 'Get started with your AI workers.',
? 'account.welcome_subtitle'
: 'account.create_subtitle',
);

const submitLabel = computed(() =>
props.submitting
? props.mode === 'login'
? 'Signing in…'
: 'Creating account…'
: props.mode === 'login'
? 'Sign in'
: 'Create account',
const submitLabelKey = computed(() => {
if (props.submitting) {
return props.mode === 'login'
? 'account.submit_login_pending'
: 'account.submit_register_pending';
}
return props.mode === 'login'
? 'account.submit_login'
: 'account.submit_register';
});

const failureKey = computed(() =>
props.mode === 'login' ? 'account.login_failed' : 'account.register_failed',
);

const inputClass = (field: 'name' | 'email' | 'password') =>
Expand All @@ -103,8 +116,8 @@ function showError(field: 'name' | 'email' | 'password') {
<template>
<div>
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight">{{ title }}</h1>
<p class="mt-1.5 text-sm text-muted-foreground">{{ subtitle }}</p>
<h1 class="text-2xl font-bold tracking-tight">{{ $t(titleKey) }}</h1>
<p class="mt-1.5 text-sm text-muted-foreground">{{ $t(subtitleKey) }}</p>
</div>

<form class="space-y-4" novalidate @submit.prevent="onSubmit">
Expand All @@ -113,7 +126,7 @@ function showError(field: 'name' | 'email' | 'password') {
for="auth-name"
class="mb-1.5 block text-xs font-medium text-foreground/80"
>
Name
{{ $t('account.name_label') }}
</label>
<div class="relative">
<Icon
Expand All @@ -126,7 +139,7 @@ function showError(field: 'name' | 'email' | 'password') {
v-model="form.name"
type="text"
autocomplete="name"
placeholder="Jane Doe"
:placeholder="$t('account.name_placeholder')"
:aria-invalid="showError('name')"
:class="inputClass('name')"
@blur="onBlur('name')"
Expand All @@ -137,7 +150,7 @@ function showError(field: 'name' | 'email' | 'password') {
class="mt-1.5 text-xs text-destructive flex items-center gap-1"
>
<Icon name="alert-circle" :size="12" />
{{ errors.name }}
{{ $t(errors.name!) }}
</p>
</div>

Expand All @@ -146,7 +159,7 @@ function showError(field: 'name' | 'email' | 'password') {
for="auth-email"
class="mb-1.5 block text-xs font-medium text-foreground/80"
>
Email
{{ $t('account.email_label') }}
</label>
<div class="relative">
<Icon
Expand All @@ -159,7 +172,7 @@ function showError(field: 'name' | 'email' | 'password') {
v-model="form.email"
type="email"
autocomplete="email"
placeholder="jane@example.com"
:placeholder="$t('account.email_placeholder')"
:aria-invalid="showError('email')"
:class="inputClass('email')"
@blur="onBlur('email')"
Expand All @@ -170,7 +183,7 @@ function showError(field: 'name' | 'email' | 'password') {
class="mt-1.5 text-xs text-destructive flex items-center gap-1"
>
<Icon name="alert-circle" :size="12" />
{{ errors.email }}
{{ $t(errors.email!) }}
</p>
</div>

Expand All @@ -180,13 +193,13 @@ function showError(field: 'name' | 'email' | 'password') {
for="auth-password"
class="block text-xs font-medium text-foreground/80"
>
Password
{{ $t('account.password_label') }}
</label>
<span
v-if="mode === 'register'"
class="text-xs text-muted-foreground"
>
Min 8 characters
{{ $t('account.password_hint') }}
</span>
</div>
<div class="relative">
Expand All @@ -208,7 +221,9 @@ function showError(field: 'name' | 'email' | 'password') {
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:aria-label="
$t(showPassword ? 'account.password_hide' : 'account.password_show')
"
@click="showPassword = !showPassword"
>
<Icon :name="showPassword ? 'eye-off' : 'eye'" :size="16" />
Expand All @@ -219,7 +234,7 @@ function showError(field: 'name' | 'email' | 'password') {
class="mt-1.5 text-xs text-destructive flex items-center gap-1"
>
<Icon name="alert-circle" :size="12" />
{{ errors.password }}
{{ $t(errors.password!) }}
</p>
</div>

Expand All @@ -232,11 +247,11 @@ function showError(field: 'name' | 'email' | 'password') {
leave-to-class="opacity-0"
>
<div
v-if="errorMessage"
v-if="failed || errorMessage"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
>
<Icon name="alert-triangle" :size="14" class="mt-px shrink-0" />
<span>{{ errorMessage }}</span>
<span>{{ errorMessage ?? $t(failureKey) }}</span>
</div>
</Transition>

Expand All @@ -251,7 +266,7 @@ function showError(field: 'name' | 'email' | 'password') {
:size="16"
class="animate-spin"
/>
{{ submitLabel }}
{{ $t(submitLabelKey) }}
<Icon
v-if="!submitting"
name="arrow-right"
Expand All @@ -264,19 +279,19 @@ function showError(field: 'name' | 'email' | 'password') {
<div class="mt-6 text-center text-sm text-muted-foreground">
<template v-if="mode === 'login'">
<span v-if="registrationEnabled">
New here?
{{ $t('account.new_here') }}
<NuxtLink to="/register" class="font-medium text-foreground hover:text-primary transition">
Create an account
{{ $t('account.create_account') }}
</NuxtLink>
</span>
<span v-else>
Need access? Ask an admin to invite you.
{{ $t('account.need_access') }}
</span>
</template>
<template v-else>
Already have an account?
{{ $t('account.have_account') }}
<NuxtLink to="/login" class="font-medium text-foreground hover:text-primary transition">
Sign in
{{ $t('account.sign_in') }}
</NuxtLink>
</template>
</div>
Expand Down
7 changes: 5 additions & 2 deletions app/slices/user/auth/components/auth/login/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ await useAsyncData('app-auth-login-registration-enabled', () =>

const submitting = ref(false);
const errorMessage = ref<string | null>(null);
const failed = ref(false);

async function onSubmit(values: { email: string; password: string }) {
submitting.value = true;
errorMessage.value = null;
failed.value = false;
try {
await authStore.login(values.email, values.password);
const target =
Expand All @@ -26,8 +28,8 @@ async function onSubmit(values: { email: string; password: string }) {
response?: { data?: { message?: string } };
message?: string;
};
errorMessage.value =
e?.response?.data?.message ?? e?.message ?? 'Login failed';
failed.value = true;
errorMessage.value = e?.response?.data?.message ?? e?.message ?? null;
} finally {
submitting.value = false;
}
Expand All @@ -39,6 +41,7 @@ async function onSubmit(values: { email: string; password: string }) {
mode="login"
:submitting="submitting"
:error-message="errorMessage"
:failed="failed"
:registration-enabled="registrationEnabled"
@submit="onSubmit"
/>
Expand Down
14 changes: 8 additions & 6 deletions app/slices/user/auth/components/auth/register/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ await useAsyncData('app-auth-registration-enabled', () => refresh());

const submitting = ref(false);
const errorMessage = ref<string | null>(null);
const failed = ref(false);

async function onSubmit(values: {
name?: string;
Expand All @@ -15,6 +16,7 @@ async function onSubmit(values: {
if (!enabled.value || !values.name) return;
submitting.value = true;
errorMessage.value = null;
failed.value = false;
try {
await authStore.register(values.name, values.email, values.password);
await navigateTo('/agents');
Expand All @@ -23,8 +25,8 @@ async function onSubmit(values: {
response?: { data?: { message?: string } };
message?: string;
};
errorMessage.value =
e?.response?.data?.message ?? e?.message ?? 'Registration failed';
failed.value = true;
errorMessage.value = e?.response?.data?.message ?? e?.message ?? null;
} finally {
submitting.value = false;
}
Expand All @@ -45,16 +47,15 @@ async function onSubmit(values: {
>
<Icon name="lock" :size="22" />
</div>
<h1 class="mt-4 text-lg font-semibold">Registration is closed</h1>
<h1 class="mt-4 text-lg font-semibold">{{ $t('account.closed_title') }}</h1>
<p class="mt-1.5 text-sm text-muted-foreground">
The administrator has disabled self-service signup.
Ask an admin to invite you, or sign in with an existing account.
{{ $t('account.closed_body') }}
</p>
<NuxtLink
to="/login"
class="mt-5 inline-flex items-center gap-1.5 rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:opacity-95 transition"
>
Go to sign in
{{ $t('account.closed_cta') }}
<Icon name="arrow-right" :size="14" />
</NuxtLink>
</div>
Expand All @@ -65,6 +66,7 @@ async function onSubmit(values: {
mode="register"
:submitting="submitting"
:error-message="errorMessage"
:failed="failed"
:registration-enabled="true"
@submit="onSubmit"
/>
Expand Down
Loading
Loading