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
8 changes: 6 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
export NX_DAEMON=false
export PATH="$HOME/.local/share/fnm/node-versions/v24.14.1/installation/bin:$HOME/.local/share/pnpm:$PATH"

pnpm exec prettier --check .
# Run lint-staged: format staged files with prettier, then ESLint for staged TS/HTML.
# This is fast because it only operates on changed files.
pnpm exec lint-staged

# Run focused lint + unit tests on the whole workspace (not just staged files).
# e2e is too slow for pre-commit; it runs in pre-push and CI instead.
pnpm exec nx run-many -t lint
pnpm exec nx run-many -t test
pnpm exec nx run-many -t e2e --parallel=1
7 changes: 7 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export NX_DAEMON=false
export PATH="$HOME/.local/share/fnm/node-versions/v24.14.1/installation/bin:$HOME/.local/share/pnpm:$PATH"

# Run E2E before push. The full gateway must be bootable; if it isn't, the test
# runner will surface that as a connection error and the push is blocked.
# This catches integration regressions before they reach the remote CI runner.
pnpm exec nx e2e app-e2e
36 changes: 21 additions & 15 deletions apps/web/app/src/app/activation/activation.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,29 @@
<app-card padding="md" tone="default">
<app-label for="activation-api-key-label" i18n="@@activationApiKeyLabel">Named API access keys</app-label>

<form [formGroup]="apiKeyForm" (ngSubmit)="createApiKey()" class="flex flex-col gap-3 md:flex-row">
<app-input
class="w-full md:flex-1"
formControlName="label"
[controlId]="'activation-api-key-label'"
invalid="!!labelError()"
name="label"
placeholder="Production workspace key"
(blur)="updateLabelError()"
/>
<app-form
class="flex flex-col gap-3 md:flex-row"
[formGroup]="apiKeyForm"
[(submitted)]="submitted"
(ngSubmit)="createApiKey()"
>
<app-field class="w-full md:flex-1">
<app-input
class="w-full"
formControlName="label"
required
maxLength="80"
controlId="activation-api-key-label"
name="label"
placeholder="Production workspace key"
/>
<app-error-message controlId="activation-api-key-label-error" i18n="@@activationApiKeyRequiredError">{{
labelError()
}}</app-error-message>
</app-field>

<app-button tone="accent" type="submit" [loading]="creatingKey()">Generate key</app-button>
</form>

@if (labelError(); as message) {
<app-error-message controlId="activation-api-key-label-error">{{ message }}</app-error-message>
}
</app-form>

<div class="mt-5 grid gap-3">
@if ((activationData()?.apiKeys?.length ?? 0) === 0) {
Expand Down
63 changes: 33 additions & 30 deletions apps/web/app/src/app/activation/activation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal, OnInit } from '@angular/core';
import { Component, computed, inject, signal, OnInit } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';

Expand All @@ -12,11 +13,14 @@ import type {
} from '../shared/activation/activation.models';
import { Clipboard } from '../shared/clipboard/clipboard';
import { PROJECTS_URL } from '../shared/constants/routes';
import { controlError } from '../shared/form/form-errors';
import { Alert } from '../shared/ui/overlays/alert/alert';
import { Badge } from '../shared/ui/data/badge/badge';
import { Button } from '../shared/ui/actions/button/button';
import { Card } from '../shared/ui/layout/card/card';
import { ErrorMessage } from '../shared/ui/forms/error-message/error-message';
import { Field } from '../shared/ui/forms/field/field';
import { Form as AppForm } from '../shared/ui/forms/form/form';
import { Heading } from '../shared/ui/typography/heading/heading';
import { Input } from '../shared/ui/forms/input/input';
import { Label } from '../shared/ui/forms/label/label';
Expand All @@ -32,7 +36,20 @@ type ConfigTab = 'env' | 'opencode' | 'themis';
host: {
class: /* tw */ 'block min-h-full w-full',
},
imports: [Alert, Badge, Button, Card, ErrorMessage, Heading, Input, Label, Loader, ReactiveFormsModule],
imports: [
Alert,
AppForm,
Badge,
Button,
Card,
ErrorMessage,
Field,
Heading,
Input,
Label,
Loader,
ReactiveFormsModule,
],
selector: 'app-activation',
templateUrl: './activation.html',
styleUrl: './activation.css',
Expand All @@ -49,26 +66,36 @@ export class Activation implements OnInit {
}),
});

private readonly labelValueChanges = toSignal(this.apiKeyForm.controls.label.valueChanges, {
initialValue: this.apiKeyForm.controls.label.status,
});

readonly activationData = signal<ActivationState | null>(null);
readonly continuing = signal(false);
readonly copyMessage = signal('');
readonly creatingKey = signal(false);
readonly errorMessage = signal('');
readonly generatedKey = signal<CreatedApiKey | null>(null);
readonly labelError = signal('');
readonly loading = signal(true);
readonly revokingKeyId = signal('');
readonly selectedConfigTab = signal<ConfigTab>('themis');
readonly submitted = signal(false);

readonly labelError = computed(() => {
this.labelValueChanges();

return controlError(this.apiKeyForm.controls.label, {
required: 'Enter a label for the API key.',
maxlength: 'Use 80 characters or fewer.',
});
});

async ngOnInit() {
await this.loadActivationState();
}

async createApiKey() {
if (this.apiKeyForm.invalid) {
this.apiKeyForm.markAllAsTouched();
this.updateLabelError();

return;
}

Expand Down Expand Up @@ -229,30 +256,6 @@ export class Activation implements OnInit {
return this.activationData()?.milestones.includes(milestone) ?? false;
}

updateLabelError() {
const control = this.apiKeyForm.controls.label;

if (!control.touched || !control.invalid) {
this.labelError.set('');

return;
}

if (control.hasError('required')) {
this.labelError.set('Enter a label for the API key.');

return;
}

if (control.hasError('maxlength')) {
this.labelError.set('Use 80 characters or fewer.');

return;
}

this.labelError.set('This field is invalid.');
}

private async loadActivationState() {
this.loading.set(true);
this.errorMessage.set('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,21 @@
}}</app-alert>
}

<form [formGroup]="form" (ngSubmit)="submit()" class="grid gap-5" novalidate>
<app-form class="grid gap-5" [formGroup]="form" [(submitted)]="submitted" (ngSubmit)="submit()">
<app-field>
<app-label for="forgotten-password-email" i18n="@@forgottenPasswordEmailLabel">Email</app-label>
<app-input
autocomplete="email"
formControlName="email"
[controlId]="'forgotten-password-email'"
[invalid]="!!emailError()"
name="email"
placeholder="name@organization.com"
required
type="email"
(blur)="updateEmailError()"
placeholder="name@organization.com"
controlId="forgotten-password-email"
name="email"
/>
@if (emailError(); as message) {
<app-error-message controlId="forgotten-password-email-error">{{ message }}</app-error-message>
}
<app-error-message controlId="forgotten-password-email-error" i18n="@@forgottenPasswordEmailErrorInvalid">{{
emailError()
}}</app-error-message>
</app-field>

<app-button
Expand All @@ -79,7 +78,7 @@
[loading]="submitting()"
>Send recovery link</app-button
>
</form>
</app-form>

<p class="mt-6 text-sm text-zinc-500 dark:text-zinc-400" i18n="@@forgottenPasswordFooterPrompt">
Remembered your password?
Expand Down
23 changes: 12 additions & 11 deletions apps/web/app/src/app/auth/forgotten-password/forgotten-password.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';

Expand All @@ -12,6 +13,7 @@ import { AuthLayout } from '../../shared/ui/layout/auth-layout/auth-layout';
import { Button } from '../../shared/ui/actions/button/button';
import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message';
import { Field } from '../../shared/ui/forms/field/field';
import { Form as AppForm } from '../../shared/ui/forms/form/form';
import { Input } from '../../shared/ui/forms/input/input';
import { Label } from '../../shared/ui/forms/label/label';
import { Link } from '../../shared/ui/typography/link/link';
Expand All @@ -24,7 +26,7 @@ type ForgottenPasswordForm = FormGroup<{
host: {
class: /* tw */ 'block min-h-full w-full',
},
imports: [Alert, AuthCard, AuthLayout, Button, ErrorMessage, Field, Input, Label, Link, ReactiveFormsModule],
imports: [Alert, AppForm, AuthCard, AuthLayout, Button, ErrorMessage, Field, Input, Label, Link, ReactiveFormsModule],
selector: 'app-forgotten-password',
templateUrl: './forgotten-password.html',
styleUrl: './forgotten-password.css',
Expand All @@ -40,27 +42,26 @@ export class ForgottenPassword {
}),
});

private readonly emailValueChanges = toSignal(this.form.controls.email.valueChanges, {
initialValue: this.form.controls.email.status,
});

readonly submitting = signal(false);
readonly submitted = signal(false);
readonly successEmail = signal('');
readonly errorMessage = signal('');
readonly emailError = signal('');

updateEmailError() {
this.emailError.set(this.emailErrorMessage());
}
readonly emailError = computed(() => {
this.emailValueChanges();

emailErrorMessage() {
return controlError(this.form.controls.email, {
email: $localize`:@@forgottenPasswordEmailErrorInvalid:Enter a valid email address (e.g. you@company.com).`,
required: $localize`:@@forgottenPasswordEmailErrorRequired:Enter your email address.`,
});
}
});

async submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
this.updateEmailError();

return;
}

Expand Down
69 changes: 24 additions & 45 deletions apps/web/app/src/app/auth/reset-password/reset-password.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,69 +53,48 @@
}}</span>
</div>

<form [formGroup]="otpForm" (ngSubmit)="submitOtp()" class="grid gap-5" novalidate>
<app-field>
<app-label for="reset-password-pin" i18n="@@resetPasswordPinLabel">Verification code</app-label>
<app-input
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
[controlId]="'reset-password-pin'"
[invalid]="!!pinError()"
name="pin"
placeholder="000000"
type="text"
(blur)="pinError.set(pinErrorMessage())"
formControlName="pin"
/>
@if (pinError(); as message) {
<app-error-message controlId="reset-password-pin-error">{{ message }}</app-error-message>
}
</app-field>

<app-button
data-od-id="submit"
data-slot="submit"
i18n="@@resetPasswordVerifyButton"
tone="accent"
type="submit"
[loading]="submitting()"
>Verify code</app-button
>
</form>
<app-verification-code-form
[errorMessage]="errorMessage()"
[pinManualError]="pinManualError()"
[statusMessage]="''"
[submitting]="verificationSubmitting()"
(verify)="onOtpSubmit($event)"
/>
} @else {
<form [formGroup]="passwordForm" (ngSubmit)="submitPassword()" class="grid gap-5" novalidate>
<app-form
class="grid gap-5"
[formGroup]="passwordForm"
[(submitted)]="submitted"
(ngSubmit)="onPasswordSubmit()"
>
<app-field>
<app-label for="reset-password-new" i18n="@@resetPasswordNewLabel">New password</app-label>
<app-password-input
autocomplete="new-password"
[controlId]="'reset-password-new'"
[invalid]="!!passwordError()"
required
minlength="8"
controlId="reset-password-new"
name="password"
placeholder="***************"
(blur)="passwordError.set(passwordErrorMessage())"
formControlName="password"
/>
<app-password-strength [password]="passwordValue" />
@if (passwordError(); as message) {
<app-error-message controlId="reset-password-new-error">{{ message }}</app-error-message>
}
</app-field>

<app-field>
<app-field [manualError]="confirmPasswordError() ?? ''">
<app-label for="reset-password-confirm" i18n="@@resetPasswordConfirmLabel">Confirm new password</app-label>
<app-password-input
autocomplete="new-password"
[controlId]="'reset-password-confirm'"
[invalid]="!!confirmPasswordError()"
required
minlength="8"
controlId="reset-password-confirm"
name="confirmPassword"
placeholder="***************"
(blur)="confirmPasswordError.set(confirmPasswordErrorMessage())"
formControlName="confirmPassword"
/>
@if (confirmPasswordError(); as message) {
<app-error-message controlId="reset-password-confirm-error">{{ message }}</app-error-message>
}
<app-error-message controlId="reset-password-confirm-error" i18n="@@resetPasswordConfirmErrorMismatch">{{
confirmPasswordError()
}}</app-error-message>
</app-field>

<app-button
Expand All @@ -127,7 +106,7 @@
[loading]="submitting()"
>Update password</app-button
>
</form>
</app-form>
}

@if (errorMessage()) {
Expand Down
Loading
Loading