From b5277f5b6b80280d88020cae33de33c8c1332fce Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:36:49 +0100 Subject: [PATCH 1/5] Rebuild Settings as one Security & accounts page Profile, schools and accounts, security and a danger zone on one page, with every change in a dialog that asks for proof the way the account can give it. /api/auth/me now says whether the account has a password and a confirmed email, and deleting an account without a password needs a fresh sign-in. --- .../Extensions/AuthenticationExtensions.cs | 2 + .../Security/AccountPasswordCheck.cs | 35 +- .../Dtos/ApplicationUserDto.cs | 4 + .../Queries/User/GetCurrentUserQuery.cs | 6 +- .../src/app/api/model/applicationUserDto.ts | 2 + src/Schuly.Frontend/src/app/core/auth-api.ts | 31 +- .../src/app/core/signed-in-at.spec.ts | 39 + .../src/app/core/signed-in-at.ts | 44 + src/Schuly.Frontend/src/app/pages/settings.ts | 92 +- .../src/app/pages/settings/account-card.ts | 188 ++-- .../src/app/pages/settings/accounts-card.ts | 95 ++- .../app/pages/settings/danger-zone-card.ts | 204 +++++ .../src/app/pages/settings/proof.ts | 70 ++ .../src/app/pages/settings/security-card.ts | 803 ++++++++++-------- .../src/app/stores/auth.store.ts | 16 +- .../src/app/stores/security.store.ts | 88 +- .../src/app/stores/user.store.ts | 2 + src/Schuly.Tests/SecurityHardeningTests.cs | 56 +- 18 files changed, 1124 insertions(+), 653 deletions(-) create mode 100644 src/Schuly.Frontend/src/app/core/signed-in-at.spec.ts create mode 100644 src/Schuly.Frontend/src/app/core/signed-in-at.ts create mode 100644 src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts create mode 100644 src/Schuly.Frontend/src/app/pages/settings/proof.ts diff --git a/src/Schuly.API/Extensions/AuthenticationExtensions.cs b/src/Schuly.API/Extensions/AuthenticationExtensions.cs index c0101cec..421f6397 100644 --- a/src/Schuly.API/Extensions/AuthenticationExtensions.cs +++ b/src/Schuly.API/Extensions/AuthenticationExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection.Extensions; using Schuly.Infrastructure; using Schuly.Infrastructure.Security; using Schuly.Infrastructure.Services; @@ -38,6 +39,7 @@ public static IServiceCollection AddSchulyAuthentication(this IServiceCollection services.AddToamaisutaaAuthenticationEventSink(); services.AddHostedService(); services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); return services; } diff --git a/src/Schuly.API/Security/AccountPasswordCheck.cs b/src/Schuly.API/Security/AccountPasswordCheck.cs index a58baf66..5fc5d610 100644 --- a/src/Schuly.API/Security/AccountPasswordCheck.cs +++ b/src/Schuly.API/Security/AccountPasswordCheck.cs @@ -6,10 +6,11 @@ namespace Schuly.API.Security { /// - /// Asks for the password again before an account is deleted, so a stolen access token alone can't delete it. An account signed in only through single sign-on has no - /// password to ask for and passes. Attempts are capped by the endpoint's rate limit; the library's own attempt counter isn't reachable from outside it. + /// Asks for proof before an account is deleted, so a stolen access token alone can't delete it. An account with a password proves it with the password. One signed in + /// only through single sign-on proves it with a sign-in within ExternalLogin:LinkProofWindow, read off the session's start the way Toamaisutaa reads it for + /// linking a provider: a token refresh doesn't move it. Attempts are capped by the endpoint's rate limit; the library's own attempt counter isn't reachable from outside it. /// - public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, IPasswordHasher hasher, IOptions options, SecurityAuditLog audit) + public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, IPasswordHasher hasher, IRefreshTokenStore refreshTokens, IOptions options, IOptions externalOptions, SecurityAuditLog audit, TimeProvider time) { public const string RefusedKind = "account-deletion-refused"; @@ -22,19 +23,41 @@ public sealed class AccountPasswordCheck(IPasswordCredentialStore credentials, I var credential = await credentials.FindByUserIdAsync(loginId, cancellationToken); if (credential is null) - return null; + { + if (await SignedInRecentlyAsync(user, cancellationToken)) + return null; + + await audit.WriteAsync(RefusedKind, loginId, null, time.GetUtcNow(), cancellationToken); + return "To delete your account, sign in again first, then try while that sign-in is fresh."; + } if (string.IsNullOrEmpty(password)) return "Enter your password to delete your account. If your app doesn't ask for it, update the app."; - if (credential.LockedOutUntil is { } until && until > DateTimeOffset.UtcNow) + if (credential.LockedOutUntil is { } until && until > time.GetUtcNow()) return "Too many wrong passwords. Try again later."; if (password.Length <= options.Value.MaximumPasswordLength && hasher.Verify(password, credential.PasswordHash) != PasswordVerificationResult.Failed) return null; - await audit.WriteAsync(RefusedKind, loginId, null, DateTimeOffset.UtcNow, cancellationToken); + await audit.WriteAsync(RefusedKind, loginId, null, time.GetUtcNow(), cancellationToken); return "Your password is not correct."; } + + private async Task SignedInRecentlyAsync(ClaimsPrincipal user, CancellationToken cancellationToken) + { + DateTimeOffset? at = null; + if (Guid.TryParse(user.FindFirst(ToamaisutaaDefaults.SessionIdClaim)?.Value, out var family) && await refreshTokens.FindLiveByFamilyAsync(family, cancellationToken) is { } live) + at = live.FamilyStartedAt; + + foreach (var type in new[] { ToamaisutaaDefaults.SecondFactorAtClaim, "auth_time" }) + { + if (long.TryParse(user.FindFirst(type)?.Value, out var seconds) && DateTimeOffset.FromUnixTimeSeconds(seconds) is var claimed && (at is null || claimed > at)) + at = claimed; + } + + var now = time.GetUtcNow(); + return at is { } signedIn && signedIn <= now && now - signedIn <= externalOptions.Value.LinkProofWindow; + } } } diff --git a/src/Schuly.Application/Dtos/ApplicationUserDto.cs b/src/Schuly.Application/Dtos/ApplicationUserDto.cs index 8ca189b7..b07a5edb 100644 --- a/src/Schuly.Application/Dtos/ApplicationUserDto.cs +++ b/src/Schuly.Application/Dtos/ApplicationUserDto.cs @@ -7,6 +7,10 @@ public class ApplicationUserDto public required string Email { get; set; } public string? DisplayName { get; set; } public string? ProfilePictureUrl { get; set; } + /// False for an account signed in only through single sign-on, which can set a first password after a fresh sign-in. + public bool HasPassword { get; set; } + /// True when is the sign-in address and somebody proved it. + public bool EmailConfirmed { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public List SchoolUsers { get; set; } = new List(); diff --git a/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs b/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs index 76c5e2d3..31de626f 100644 --- a/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs +++ b/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs @@ -6,13 +6,14 @@ using Schuly.Infrastructure; using Schuly.Infrastructure.Services; using Schuly.Application.Authorization; +using Toamaisutaa.Abstractions; namespace Schuly.Application.Queries.User { [AllowAuthenticated] public record GetCurrentUserQuery() : IQuery>; - public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner) : IQueryHandler> + public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner, IPasswordCredentialStore credentials) : IQueryHandler> { public async ValueTask> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken) { @@ -27,6 +28,9 @@ public async ValueTask> Handle(GetCurrentUserQuery qu var dto = user.ToDto(); dto.ProfilePictureUrl = avatarUrlSigner.ToPublicUserUrl(user.Id, user.ProfilePictureUrl); + var credential = Guid.TryParse(user.ExternalId, out var loginId) ? await credentials.FindByUserIdAsync(loginId, cancellationToken) : null; + dto.HasPassword = credential is not null; + dto.EmailConfirmed = credential?.EmailConfirmedAt is not null && string.Equals(credential.Email, user.Email, StringComparison.OrdinalIgnoreCase); return Result.Success(dto); } } diff --git a/src/Schuly.Frontend/src/app/api/model/applicationUserDto.ts b/src/Schuly.Frontend/src/app/api/model/applicationUserDto.ts index 48fdd53c..033257ad 100644 --- a/src/Schuly.Frontend/src/app/api/model/applicationUserDto.ts +++ b/src/Schuly.Frontend/src/app/api/model/applicationUserDto.ts @@ -16,6 +16,8 @@ export interface ApplicationUserDto { email: string; displayName?: string | null; profilePictureUrl?: string | null; + hasPassword?: boolean; + emailConfirmed?: boolean; createdAt?: string; updatedAt?: string; schoolUsers?: Array; diff --git a/src/Schuly.Frontend/src/app/core/auth-api.ts b/src/Schuly.Frontend/src/app/core/auth-api.ts index db43a01f..ac13df64 100644 --- a/src/Schuly.Frontend/src/app/core/auth-api.ts +++ b/src/Schuly.Frontend/src/app/core/auth-api.ts @@ -58,6 +58,16 @@ export interface LinkedSignIn { last_sign_in_at?: string | null; } +export interface SessionSummary { + id: string; + userAgent?: string | null; + ipAddress?: string | null; + createdAt: string; + lastUsedAt: string; + expiresAt: string; + isCurrent?: boolean; +} + export interface SignInProof { currentPassword?: string; code?: string; @@ -135,8 +145,17 @@ export class AuthApi { return this.http.post('/auth/password/reset', { token, newPassword }); } - changePassword(currentPassword: string, newPassword: string): Observable { - return this.http.post('/auth/password', { currentPassword, newPassword }); + /** Leave out `currentPassword` to set a first password, which the server allows only right after a sign-in. */ + changePassword(currentPassword: string | null, newPassword: string): Observable { + return this.http.post('/auth/password', currentPassword === null ? { newPassword } : { currentPassword, newPassword }); + } + + sessions(): Observable { + return this.http.get('/auth/sessions'); + } + + revokeSession(id: string): Observable { + return this.http.delete(`/auth/sessions/${encodeURIComponent(id)}`); } requestEmailVerification(newEmail: string, currentPassword: string): Observable { @@ -151,16 +170,16 @@ export class AuthApi { return this.http.get('/auth/2fa'); } - beginTwoFactor(currentPassword: string): Observable { - return this.http.post('/auth/2fa/begin', { currentPassword }); + beginTwoFactor(currentPassword: string | null): Observable { + return this.http.post('/auth/2fa/begin', currentPassword ? { currentPassword } : {}); } confirmTwoFactor(code: string): Observable<{ recoveryCodes: string[] }> { return this.http.post<{ recoveryCodes: string[] }>('/auth/2fa/confirm', { code }); } - disableTwoFactor(proof: string, currentPassword: string): Observable { - return this.http.post('/auth/2fa/disable', { proof, currentPassword }); + disableTwoFactor(proof: string, currentPassword: string | null): Observable { + return this.http.post('/auth/2fa/disable', currentPassword ? { proof, currentPassword } : { proof }); } trustedDevices(): Observable { diff --git a/src/Schuly.Frontend/src/app/core/signed-in-at.spec.ts b/src/Schuly.Frontend/src/app/core/signed-in-at.spec.ts new file mode 100644 index 00000000..ffc6a42c --- /dev/null +++ b/src/Schuly.Frontend/src/app/core/signed-in-at.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { FRESH_SIGN_IN_MS, clearSignedIn, describeAgent, markSignedIn, proofMode, signedInRecently } from './signed-in-at'; + +describe('signedInRecently', () => { + afterEach(() => clearSignedIn()); + + it('is true only shortly after a real sign-in', () => { + const now = 1_800_000_000_000; + expect(signedInRecently(now)).toBe(false); + markSignedIn(now - 60_000); + expect(signedInRecently(now)).toBe(true); + expect(signedInRecently(now + FRESH_SIGN_IN_MS)).toBe(false); + }); + + it('does not trust a time in the future', () => { + markSignedIn(2_000); + expect(signedInRecently(1_000)).toBe(false); + }); +}); + +describe('proofMode', () => { + it('asks for the password whenever there is one', () => { + expect(proofMode(true, true)).toBe('password'); + expect(proofMode(true, false)).toBe('password'); + }); + + it('asks a passwordless account for nothing after a fresh sign-in, else to sign in again', () => { + expect(proofMode(false, true)).toBe('none'); + expect(proofMode(false, false)).toBe('reauth'); + }); +}); + +describe('describeAgent', () => { + it('names browser and system', () => { + expect(describeAgent('Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0')).toBe('Firefox on Linux'); + expect(describeAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36 OPR/124.0')).toBe('Opera on Windows'); + expect(describeAgent(null)).toBe('Unknown device'); + }); +}); diff --git a/src/Schuly.Frontend/src/app/core/signed-in-at.ts b/src/Schuly.Frontend/src/app/core/signed-in-at.ts new file mode 100644 index 00000000..9d1394a3 --- /dev/null +++ b/src/Schuly.Frontend/src/app/core/signed-in-at.ts @@ -0,0 +1,44 @@ +const key = 'schuly-signed-in-at'; + +/** How long the server takes a sign-in as proof. Kept a little under its five minutes so the request still lands inside the window. */ +export const FRESH_SIGN_IN_MS = 4 * 60_000; + +/** Called for a real sign-in, never for a token refresh, which the server does not count as one either. */ +export function markSignedIn(at = Date.now()): void { + try { + sessionStorage.setItem(key, String(at)); + } catch {} +} + +export function clearSignedIn(): void { + try { + sessionStorage.removeItem(key); + } catch {} +} + +/** Only a hint for which proof to ask for; the server decides from the session itself. */ +export function signedInRecently(now = Date.now()): boolean { + try { + const at = Number(sessionStorage.getItem(key)); + return at > 0 && at <= now && now - at < FRESH_SIGN_IN_MS; + } catch { + return false; + } +} + +/** How this account proves it's the owner: the password if it has one, otherwise nothing right after a sign-in, else signing in again at a linked provider. */ +export type ProofMode = 'none' | 'password' | 'reauth'; + +export function proofMode(hasPassword: boolean, fresh = signedInRecently()): ProofMode { + if (hasPassword) return 'password'; + return fresh ? 'none' : 'reauth'; +} + +/** "Firefox on Linux" from a raw user agent; the Schuly app names itself. */ +export function describeAgent(agent: string | null | undefined): string { + if (!agent) return 'Unknown device'; + if (/^schuly|dart\//i.test(agent)) return /android/i.test(agent) ? 'Schuly app on Android' : /ios|iphone|ipad/i.test(agent) ? 'Schuly app on iOS' : 'Schuly app'; + const browser = /Edg\//.test(agent) ? 'Edge' : /OPR\//.test(agent) ? 'Opera' : /Firefox\//.test(agent) ? 'Firefox' : /Chrome\//.test(agent) ? 'Chrome' : /Safari\//.test(agent) ? 'Safari' : 'Browser'; + const system = /Windows/.test(agent) ? 'Windows' : /Android/.test(agent) ? 'Android' : /iPhone|iPad/.test(agent) ? 'iOS' : /Mac OS/.test(agent) ? 'macOS' : /Linux/.test(agent) ? 'Linux' : ''; + return system ? `${browser} on ${system}` : browser; +} diff --git a/src/Schuly.Frontend/src/app/pages/settings.ts b/src/Schuly.Frontend/src/app/pages/settings.ts index d65c1cdf..a548e86b 100644 --- a/src/Schuly.Frontend/src/app/pages/settings.ts +++ b/src/Schuly.Frontend/src/app/pages/settings.ts @@ -1,47 +1,39 @@ import { LanguageSelect } from '../shared/language-select'; import { TranslatePipe } from '@ngx-translate/core'; -import { Component, inject, signal } from '@angular/core'; +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { - lucideDownload, lucideMonitor, lucideMoon, lucideSun, - lucideTrash2, } from '@ng-icons/lucide'; -import { HlmAlertDialogImports } from '@spartan-ng/helm/alert-dialog'; -import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; -import { HlmInput } from '@spartan-ng/helm/input'; -import { HlmSpinner } from '@spartan-ng/helm/spinner'; import { HlmToggleGroupImports } from '@spartan-ng/helm/toggle-group'; import { Theme, ThemeStore } from '../stores/theme.store'; import { AccountCard } from './settings/account-card'; import { SecurityCard } from './settings/security-card'; import { AccountsCard } from './settings/accounts-card'; -import { UserStore } from '../stores/user.store'; +import { DangerZoneCard } from './settings/danger-zone-card'; @Component({ selector: 'app-settings', imports: [ - LanguageSelect,TranslatePipe, + LanguageSelect,TranslatePipe, AccountCard, SecurityCard, AccountsCard, + DangerZoneCard, HlmCardImports, - HlmButton, - HlmInput, HlmToggleGroupImports, - HlmAlertDialogImports, - HlmSpinner, NgIcon, ], - providers: [provideIcons({ lucideSun, lucideMoon, lucideMonitor, lucideDownload, lucideTrash2 })], + providers: [provideIcons({ lucideSun, lucideMoon, lucideMonitor })], template: `
- +
@@ -73,79 +65,19 @@ import { UserStore } from '../stores/user.store';
-
-
-

{{ 'settings.data.title' | translate }}

-

{{ 'settings.data.description' | translate }}

-
-
- -
-
- -
-
-

{{ 'settings.delete.title' | translate }}

-

- {{ 'settings.delete.description' | translate }} -

- @if (user.deleteError(); as error) { - - } -
-
- - - - -

{{ 'settings.delete.confirmTitle' | translate }}

-

- {{ 'settings.delete.confirmDescription' | translate }} -

-
- - - - - -
-
-
-
+

{{ 'auth.layout.trademarks' | translate }}

`, }) export class Settings { - protected readonly user = inject(UserStore); protected readonly theme = inject(ThemeStore); - protected readonly deletePassword = signal(''); + /** Set when a fresh sign-in came back here to finish an action; read once, then taken off the address. */ + protected readonly resume = inject(ActivatedRoute).snapshot.queryParamMap.get('resume'); - protected confirmDelete(): void { - this.user.deleteAccount(this.deletePassword()); - this.deletePassword.set(''); + constructor() { + if (this.resume) void inject(Router).navigate([], { queryParams: { resume: null }, queryParamsHandling: 'merge', replaceUrl: true }); } protected setTheme(value: unknown): void { diff --git a/src/Schuly.Frontend/src/app/pages/settings/account-card.ts b/src/Schuly.Frontend/src/app/pages/settings/account-card.ts index 0b04f686..0de32a41 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/account-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/account-card.ts @@ -1,161 +1,82 @@ import { Component, inject, signal } from '@angular/core'; -import { email, form, FormField, FormRoot, required } from '@angular/forms/signals'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucideImageUp, lucideX } from '@ng-icons/lucide'; +import { lucidePencil } from '@ng-icons/lucide'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmAvatarImports } from '@spartan-ng/helm/avatar'; -import { HlmBadge } from '@spartan-ng/helm/badge'; -import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; -import { HlmFieldImports } from '@spartan-ng/helm/field'; -import { HlmInput } from '@spartan-ng/helm/input'; +import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; -import { SecurityStore } from '../../stores/security.store'; import { UserStore } from '../../stores/user.store'; +/** One profile line: the picture, changed from a small menu on it, then the name and sign-in email. */ @Component({ selector: 'app-account-card', - imports: [ - FormRoot, - FormField, - NgIcon, - HlmAlertImports, - HlmAvatarImports, - HlmBadge, - HlmButton, - HlmCardImports, - HlmFieldImports, - HlmInput, - HlmSpinner, - ], - providers: [provideIcons({ lucideImageUp, lucideX })], + imports: [NgIcon, HlmAlertImports, HlmAvatarImports, HlmCardImports, HlmDropdownMenuImports, HlmSpinner], + providers: [provideIcons({ lucidePencil })], template: ` -
-
-

Account

-

Your profile picture and sign-in email.

-
-
-
- - @if (user.avatar(); as src) { - - } - {{ user.initials() }} - -
- {{ user.name() }} - {{ user.schoolCount() }} linked - {{ user.schoolCount() === 1 ? 'school' : 'schools' }} -
-
- - +
+

{{ user.name() }}

+

+ {{ user.email() }} + @if (user.email()) { + · + @if (user.emailConfirmed()) { + Confirmed + } @else { + Not confirmed + } } - Change picture - - @if (user.avatar()) { - - } -

-
- -
-
- Email - @if (user.email()) { - Confirmed - } @else { - Not confirmed - } -
- @if (user.email()) { -

{{ user.email() }}

- } @else { -

- Confirm your email so you can reset your password if you forget it.

-
-
- - -
-
- - -
- -
- } +
@if (picError()) { -
+

{{ picError() }}

}
+ + + + + @if (user.avatar()) { + + } + + `, }) export class AccountCard { protected readonly user = inject(UserStore); - protected readonly security = inject(SecurityStore); protected readonly picError = signal(null); - private readonly verificationModel = signal({ email: '', password: '' }); - protected readonly verification = form( - this.verificationModel, - (path) => { - required(path.email); - email(path.email); - required(path.password); - }, - { submission: { action: () => this.resend() } }, - ); protected upload(input: HTMLInputElement): void { const file = input.files?.[0]; @@ -168,11 +89,4 @@ export class AccountCard { this.picError.set(null); this.user.uploadAvatar(file); } - - private async resend(): Promise { - const { email, password } = this.verificationModel(); - await this.security.resendVerification(email.trim(), password); - this.verificationModel.update((model) => ({ ...model, password: '' })); - return undefined; - } } diff --git a/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts b/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts index dc7550f2..7a4d7ace 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts @@ -3,11 +3,10 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucidePlus, lucideRefreshCw, lucideUnplug } from '@ng-icons/lucide'; +import { lucidePlus } from '@ng-icons/lucide'; import { toast } from '@spartan-ng/brain/sonner'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmAlertDialogImports } from '@spartan-ng/helm/alert-dialog'; -import { HlmBadge } from '@spartan-ng/helm/badge'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; @@ -17,25 +16,31 @@ import { ProviderIcon } from '../../shared/provider-icon'; import { LinkedAccountsStore } from '../../stores/linked-accounts.store'; import { LinkDialog } from './link-dialog'; -const STATUS: Record = { - ok: { label: 'Connected', variant: 'secondary', tone: '' }, - syncing: { label: 'Syncing', variant: 'outline', tone: '' }, - expired: { label: 'Sign-in expired', variant: 'outline', tone: 'border-destructive text-destructive' }, - error: { label: 'Problem', variant: 'outline', tone: 'border-destructive text-destructive' }, +const STATUS: Record = { + ok: { label: 'Connected', dot: 'bg-green-500', tone: '' }, + syncing: { label: 'Syncing', dot: 'bg-muted-foreground', tone: '' }, + expired: { label: 'Sign-in expired', dot: 'bg-amber-500', tone: 'text-amber-700 dark:text-amber-300' }, + error: { label: 'Problem', dot: 'bg-destructive', tone: 'text-destructive' }, }; /** Every linked school and service, grouped by provider, with add, re-authenticate and unlink. Nothing here knows a provider by name. */ @Component({ selector: 'app-accounts-card', - imports: [HlmAlertImports, HlmAlertDialogImports, HlmBadge, HlmButton, HlmCardImports, HlmSpinner, NgIcon, LinkDialog, ProviderIcon], - providers: [provideIcons({ lucidePlus, lucideRefreshCw, lucideUnplug })], + imports: [HlmAlertImports, HlmAlertDialogImports, HlmButton, HlmCardImports, HlmSpinner, NgIcon, LinkDialog, ProviderIcon], + providers: [provideIcons({ lucidePlus })], template: `
-
-

Schools & accounts

-

The schools and services Schuly syncs for you.

+
+
+

Schools & accounts

+

The schools and services Schuly syncs for you.

+
+
-
+
@if (store.loadError(); as problem) {

{{ problem }}

@@ -52,49 +57,54 @@ const STATUS: Record -

+
- {{ group.name }} -

-
    +
    +

    {{ group.name }}

    + @if (group.provider?.description; as description) { +

    {{ description }}

    + } +
    +
+
    @for (account of group.accounts; track account.id) { @if (!account.id) { -
  • +
  • Could not load the accounts {{ account.message || 'This service did not answer. Try again later.' }}
  • } @else { -
  • +
  • {{ account.title }} - @if (account.detail) { - {{ account.detail }} - } - - {{ status(account).label }} - @if (account.message) { - {{ account.message }} + + @if (account.detail) { + {{ account.detail }} + } + + + {{ status(account).label }} + + @if (account.message) { + {{ account.message }} + } - + @if (account.canReauth) { - + @if (account.status === 'expired') { + + } @else { + + } } - +

    Unlink {{ account.title }}?

    -

    - Schuly stops syncing this account and removes its stored sign-in and the data it synced. You can add it again from here at any time. -

    +

    Schuly stops reading it and deletes what it stored from it. You can add it again any time.

    @@ -110,15 +120,8 @@ const STATUS: Record } } @else if (!store.loadError()) { -

    No schools or accounts linked yet.

    +

    No schools or accounts linked yet. Add one to start syncing.

    } - -
    - -
diff --git a/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts b/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts new file mode 100644 index 00000000..8e945001 --- /dev/null +++ b/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts @@ -0,0 +1,204 @@ +import { Component, computed, effect, inject, input, signal, untracked, viewChild } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideDownload, lucideTriangleAlert } from '@ng-icons/lucide'; +import { HlmAlertImports } from '@spartan-ng/helm/alert'; +import { HlmButton } from '@spartan-ng/helm/button'; +import { HlmCardImports } from '@spartan-ng/helm/card'; +import { HlmDialogImports } from '@spartan-ng/helm/dialog'; +import { HlmFieldImports } from '@spartan-ng/helm/field'; +import { HlmInput } from '@spartan-ng/helm/input'; +import { HlmSpinner } from '@spartan-ng/helm/spinner'; +import { SecurityStore } from '../../stores/security.store'; +import { UserStore } from '../../stores/user.store'; +import { Proof } from './proof'; + +type Dialog = 'email' | 'export' | 'delete'; + +/** Changes to the account itself: its email, a copy of its data, and deleting it. */ +@Component({ + selector: 'app-danger-zone-card', + imports: [NgIcon, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, HlmSpinner, Proof], + providers: [provideIcons({ lucideDownload, lucideTriangleAlert })], + template: ` +
+
+

Danger zone

+

Changes to the account itself.

+
+
+
    +
  • +
    + Change email + Where Schuly sends codes and notices. +
    + +
  • +
  • +
    + Export your data + A file with your profile, linked accounts and synced school data. +
    + +
  • +
  • +
    + Delete account + Removes your account and everything Schuly stored. This can't be undone. +
    + +
  • +
+
+
+ + + + @switch (dialog()) { + @case ('email') { + +

Change email

+

We send a link to the new address; your email changes once you open it.

+
+ @if (!user.hasPassword()) { +
+

Add a password first

+

Your email here belongs to the provider you sign in with. Add a password in Security, then you can choose your own.

+
+ + + + } @else if (sentTo()) { +
+

Check your inbox

+

We sent a link to {{ sentTo() }}. Open it to finish the change.

+
+ + + + } @else { +
+
+ + +
+ + @if (error()) { + + } + + + + + + } + } + @case ('export') { + +

Export your data

+

Download your account, school data, Helply chats, connected apps and devices as a JSON file. Document files stay in Documents, and the file lists anything else it leaves out.

+
+ + + + + } + @case ('delete') { + +

Delete your account?

+

Your profile, linked accounts and all synced school data are deleted. Your school accounts themselves stay untouched.

+
+
+
+
+ + @if (proof.mode() !== 'reauth') { +
+ + +
+ } + @if (user.deleteError(); as problem) { + + } + + + + + + } + } +
+
+ `, +}) +export class DangerZoneCard { + protected readonly user = inject(UserStore); + private readonly security = inject(SecurityStore); + /** A settings action to reopen after signing in again. */ + readonly resume = input(null); + + protected readonly dialog = signal(null); + protected readonly password = signal(''); + protected readonly newEmail = signal(''); + protected readonly confirmText = signal(''); + protected readonly sentTo = signal(null); + protected readonly busy = computed(() => this.security.busy()); + protected readonly error = signal(null); + private readonly proof = viewChild('proof'); + private resumed = false; + + constructor() { + effect(() => { + const resume = this.resume(); + if (this.resumed || !this.user.user() || !resume) return; + this.resumed = true; + untracked(() => { + if (resume === 'email' || resume === 'delete') this.open(resume); + }); + }); + } + + protected open(dialog: Dialog): void { + this.password.set(''); + this.newEmail.set(''); + this.confirmText.set(''); + this.sentTo.set(null); + this.error.set(null); + this.dialog.set(dialog); + } + + protected close(): void { + this.dialog.set(null); + } + + protected validEmail(): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.newEmail().trim()); + } + + private passwordOrNull(): string | null { + return this.proof()?.mode() === 'password' ? this.password() : null; + } + + protected async changeEmail(event: Event): Promise { + event.preventDefault(); + const email = this.newEmail().trim(); + if (await this.security.requestEmailChange(email, this.password())) this.sentTo.set(email); + else this.error.set(this.security.error()); + } + + protected deleteAccount(event: Event): void { + event.preventDefault(); + this.user.deleteAccount(this.passwordOrNull() ?? ''); + } +} diff --git a/src/Schuly.Frontend/src/app/pages/settings/proof.ts b/src/Schuly.Frontend/src/app/pages/settings/proof.ts new file mode 100644 index 00000000..d24bad0b --- /dev/null +++ b/src/Schuly.Frontend/src/app/pages/settings/proof.ts @@ -0,0 +1,70 @@ +import { Component, computed, inject, input, model, signal } from '@angular/core'; +import { HlmButton } from '@spartan-ng/helm/button'; +import { HlmFieldImports } from '@spartan-ng/helm/field'; +import { HlmInput } from '@spartan-ng/helm/input'; +import { ProofMode, proofMode } from '../../core/signed-in-at'; +import { AuthStore } from '../../stores/auth.store'; +import { SecurityStore } from '../../stores/security.store'; +import { UserStore } from '../../stores/user.store'; + +/** The proof part of a settings dialog. `resume` is the settings action to reopen after signing in again. */ +@Component({ + selector: 'app-proof', + imports: [HlmButton, HlmFieldImports, HlmInput], + template: ` + @switch (mode()) { + @case ('password') { +
+ +
+ + +
+
+ } + @case ('reauth') { +
+

To {{ what() }}, confirm it's you by signing in again. You come straight back here.

+ @if (provider(); as p) { + + } @else { +

Sign out and sign in again, then try once more.

+ } +
+ } + } + `, +}) +export class Proof { + private readonly auth = inject(AuthStore); + private readonly security = inject(SecurityStore); + private readonly user = inject(UserStore); + + readonly what = input.required(); + readonly resume = input.required(); + readonly inputId = input('proof-password'); + readonly password = model(''); + protected readonly shown = signal(false); + + /** Read once per dialog: the dialog is rebuilt each time it opens. */ + readonly mode = computed(() => proofMode(this.user.hasPassword())); + protected readonly provider = computed(() => this.security.linkedRows()[0] ?? null); + + /** True when the action can be sent as it stands. */ + readonly ready = computed(() => this.mode() === 'none' || (this.mode() === 'password' && this.password().length > 0)); + + protected reauth(provider: string): void { + void this.auth.reauthenticate(provider, `/settings?resume=${encodeURIComponent(this.resume())}`); + } +} diff --git a/src/Schuly.Frontend/src/app/pages/settings/security-card.ts b/src/Schuly.Frontend/src/app/pages/settings/security-card.ts index bddca1aa..4cbe268b 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/security-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/security-card.ts @@ -1,318 +1,406 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, input, signal, untracked, viewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { form, FormField, FormRoot, minLength, required } from '@angular/forms/signals'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideArrowLeft, lucideKeyRound, lucidePlus } from '@ng-icons/lucide'; +import { toast } from '@spartan-ng/brain/sonner'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; -import { HlmBadge } from '@spartan-ng/helm/badge'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; +import { HlmDialogImports } from '@spartan-ng/helm/dialog'; import { HlmFieldImports } from '@spartan-ng/helm/field'; import { HlmInput } from '@spartan-ng/helm/input'; -import { HlmSeparator } from '@spartan-ng/helm/separator'; -import { SecurityStore } from '../../stores/security.store'; -import { AppDatePipe } from '../../core/date-format'; +import { SessionSummary, SignInProof } from '../../core/auth-api'; +import { formatModified } from '../../core/date-format'; +import { describeAgent } from '../../core/signed-in-at'; +import { ProviderRow, SecurityStore } from '../../stores/security.store'; +import { UserStore } from '../../stores/user.store'; +import { Proof } from './proof'; +type Dialog = + | { kind: 'password' } + | { kind: 'twoFactor' } + | { kind: 'twoFactorOff' } + | { kind: 'linkPick' } + | { kind: 'link'; provider: ProviderRow } + | { kind: 'unlink'; provider: ProviderRow } + | { kind: 'lastWayIn'; provider: ProviderRow } + | { kind: 'signOut'; session: SessionSummary } + | { kind: 'signInAgain' }; + +const MIN_PASSWORD = 8; + +/** How you sign in and where you're signed in. The rows say the current state; every change happens in a dialog that asks for proof the way this account can give it. */ @Component({ selector: 'app-security-card', - imports: [ - AppDatePipe, - FormRoot, - FormField, - HlmAlertImports, - HlmBadge, - HlmButton, - HlmCardImports, - HlmFieldImports, - HlmInput, - HlmSeparator, - ], + imports: [NgIcon, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, Proof], + providers: [provideIcons({ lucideArrowLeft, lucideKeyRound, lucidePlus })], template: ` -
+
-

Security

-

Your password, two-factor authentication, sign-in providers and remembered devices.

+

Security

+

How you sign in to Schuly, and where you're signed in.

-
- @if (security.signInAgain() && !security.recoveryCodes()) { -
-

{{ security.signInAgain() }}

-

- For your safety, changing how you sign in ends every session, including this one. -

-
- - } - @if (security.notice()) { -
+
+ @if (security.notice() && !dialog()) { +

{{ security.notice() }}

} - @if (security.error()) { -
+ @if (security.error() && !dialog()) { + } -
- Change password -
-
- - +
    +
  • +
    + Password + + + {{ user.hasPassword() ? 'Set' : 'Not set · you sign in with a provider' }} +
    -
    - - + +
  • +
  • +
    + Two-factor authentication + + + @if (twoFactorOn()) { + On · {{ security.twoFactor()?.recoveryCodesRemaining ?? 0 }} recovery codes left + } @else { + Off · a code from your phone at each new sign-in + } +
    - -
- + + - + @if (security.providers().length || security.linked().length) { +
+

Sign-in providers

+ @if (security.unlinkedRows().length) { + + } +
+ @if (security.linkedRows().length) { +
    + @for (provider of security.linkedRows(); track provider.key) { +
  • + + + {{ provider.name }} + + +
  • + } +
+ } @else { +

No provider linked. Link one to sign in without your password.

+ } + } -
-
- Two-factor authentication - @if (security.twoFactor()?.enabled) { - On - } @else { - Off + @if (security.sessions().length) { +

Devices

+
    + @for (session of security.sessions(); track session.id) { +
  • + + {{ deviceName(session) }} + Last active: {{ when(session.lastUsedAt) }} + + @if (session.isCurrent) { + + + This device + + } @else { + + } +
  • } +
+ } + @if (security.devices().length) { +
+ {{ security.devices().length }} {{ security.devices().length === 1 ? 'device skips' : 'devices skip' }} the two-factor code. +
+ } +
+
- @if (security.recoveryCodes(); as codes) { -
-

Save your recovery codes

-

- Each one signs you in once if you lose your phone. They are only shown now. + + + @switch (dialog()?.kind) { + @case ('password') { + +

{{ user.hasPassword() ? 'Change password' : 'Add a password' }}

+

+ {{ user.hasPassword() ? 'Then sign in again with the new one, here and on your other devices.' : 'Then you can also sign in with your email and this password.' }}

-
-
    - @for (code of codes; track code) { -
  • {{ code }}
  • + +
    + + @if (proof.mode() !== 'reauth') { +
    + + +

    At least {{ minPassword }} characters. A sentence you'll remember works well.

    +
    } -
- - } @else if (security.setup(); as setup) { -

- Scan this with an authenticator app, then enter the 6-digit code it shows. -

-
- QR code for your authenticator app - + @if (security.error()) { + + } + + + + + + } + @case ('twoFactor') { + +

Set up two-factor authentication

+

Step {{ twoFactorStep() }} of 3 · {{ stepLabel() }}

+
+ + @if (security.recoveryCodes(); as codes) { +
+

Save your recovery codes

+

Each one signs you in once if you lose your phone. Keep them somewhere safe, not only on this device. They're only shown now.

+
+
    + @for (code of codes; track code) { +
  • {{ code }}
  • + } +
+ + + + + } @else if (security.setup(); as setup) { +
+

Scan this with your authenticator app, then type the 6-digit code it shows.

+ QR code for your authenticator app +

Can't scan? Enter {{ setup.secret }}

- - + +
-

- Or type this key: {{ setup.secret }} -

-
- - -
+ + +
-
- } @else if (security.twoFactor()?.enabled) { -

- {{ security.twoFactor()?.recoveryCodesRemaining ?? 0 }} recovery codes left. -

-
-
- - -
-
- - -
- -
- } @else { -

- Ask for a code from your phone each time you sign in on a new device. -

-
+ } @else { + + + @if (proof.mode() !== 'reauth') { +
+
+ } + @if (security.error()) { + + } + + + + + + } + } + @case ('twoFactorOff') { + +

Turn off two-factor authentication

+

Signing in then needs only your password or provider.

+
+
- - + +
- + + @if (security.error()) { + + } + + + + } - - - @if (security.providers().length || security.linked().length) { - -
- Sign-in providers -
    - @for (provider of providerRows(); track provider.key) { -
  • - - {{ provider.name }} - @if (provider.linked) { - Linked - } + @case ('linkPick') { + +

    Link a provider

    +

    Pick one to sign in with, as well as the ones you have.

    +
    +
      + @for (provider of security.unlinkedRows(); track provider.key) { +
    • + + + {{ provider.name }} - @if (provider.linked) { - - } @else { - - } +
    • }
    -
    - - -

    - Needed to link or unlink. Unlinking signs you out everywhere. -

    -
    -
- } - - @if (security.devices().length) { - -
- Remembered devices -
    - @for (device of security.devices(); track device.id) { -
  • - - {{ device.label || 'Unknown device' }} - @if (device.isCurrent) { - This device - } - - {{ - device.lastUsedAt || device.createdAt | appDate: 'date' - }} -
  • + + + + } + @case ('link') { + @if (providerOf(dialog()); as provider) { + +

    Link {{ provider.name }}

    +

    Then you can sign in with {{ provider.name }} too.

    +
    +
    + @if (twoFactorOn()) { +
    + + +
    + } @else { + + } + @if (security.error()) { + + } + + + + + + } + } + @case ('unlink') { + @if (providerOf(dialog()); as provider) { + +

    Unlink {{ provider.name }}?

    +

    You won't be able to sign in with {{ provider.name }} any more, and you'll be signed out everywhere, here too.

    +
    +
    + @if (twoFactorOn()) { +
    + + +
    + } @else { + + } + @if (security.error()) { + + } + + + + + + } + } + @case ('lastWayIn') { + @if (providerOf(dialog()); as provider) { + +

    You can't unlink {{ provider.name }} yet

    +

    It is your only way to sign in.

    +
    +
    +

    It's your only way to sign in

    +

    Add a password or link another provider first, otherwise you'd lock yourself out.

    +
    + + + + + } + } + @case ('signOut') { + @if (sessionOf(dialog()); as session) { + +

    Sign out {{ deviceName(session) }}?

    +

    It has to sign in again to use Schuly.

    +
    + @if (security.error()) { + } -
- -
+ + + + + } + } + @case ('signInAgain') { + +

{{ security.signInAgain() }}

+

For your safety, changing how you sign in ends every session, including this one.

+
+ + + + } } - -
+ + `, }) export class SecurityCard { protected readonly security = inject(SecurityStore); - protected readonly proofValue = signal(''); - protected readonly providerRows = computed(() => { - const linked = this.security.linked(); - const rows = this.security.providers().map((provider) => ({ key: provider.key, name: provider.display_name, linked: linked.some((login) => login.provider === provider.key) })); - const retired = linked.filter((login) => !rows.some((row) => row.key === login.provider)).map((login) => ({ key: login.provider, name: login.display_name ?? login.provider, linked: true })); - return [...rows, ...retired]; - }); + protected readonly user = inject(UserStore); + /** A settings action to reopen after signing in again, such as `password` or `unlink:google`. */ + readonly resume = input(null); + + protected readonly dialog = signal(null); + protected readonly currentPassword = signal(''); + protected readonly newPassword = signal(''); + protected readonly code = signal(''); + protected readonly minPassword = MIN_PASSWORD; + private readonly proof = viewChild('proof'); + + protected readonly twoFactorOn = computed(() => !!this.security.twoFactor()?.enabled); + protected readonly twoFactorStep = computed(() => (this.security.recoveryCodes() ? 3 : this.security.setup() ? 2 : 1)); + protected readonly stepLabel = computed(() => ["confirm it's you", 'connect your app', 'recovery codes'][this.twoFactorStep() - 1]); + private resumed = false; + /** Link and unlink take a code when there is a second factor, otherwise the account's usual proof. */ + protected readonly proofReady = computed(() => (this.twoFactorOn() ? this.code().trim().length > 0 : (this.proof()?.ready() ?? false))); + private readonly waysIn = computed(() => this.security.linkedRows().length + (this.user.hasPassword() ? 1 : 0)); constructor() { const query = inject(ActivatedRoute).snapshot.queryParamMap; @@ -320,80 +408,119 @@ export class SecurityCard { this.security.linkFinished(query.get('linked'), query.get('error'), query.get('state')); void inject(Router).navigate([], { queryParams: {}, replaceUrl: true }); } + effect(() => { + if (this.security.signInAgain() && !this.security.recoveryCodes()) untracked(() => this.dialog.set({ kind: 'signInAgain' })); + }); + // Reopens the action that sent the user to sign in again, once the providers it may name have loaded. + effect(() => { + const resume = this.resume(); + if (!resume || this.resumed || this.security.busy() || !this.user.user()) return; + this.resumed = true; + untracked(() => this.reopen(resume)); + }); + } + + private reopen(resume: string): void { + const [kind, key] = resume.split(':'); + const provider = (rows: ProviderRow[]) => rows.find((row) => row.key === key); + if (kind === 'password' || kind === 'twoFactor' || kind === 'twoFactorOff') this.open({ kind }); + else if (kind === 'link' && provider(this.security.unlinkedRows())) this.open({ kind: 'link', provider: provider(this.security.unlinkedRows())! }); + else if (kind === 'unlink' && provider(this.security.linkedRows())) this.open({ kind: 'unlink', provider: provider(this.security.linkedRows())! }); + } + + protected open(dialog: Dialog): void { + this.security.clearError(); + this.currentPassword.set(''); + this.newPassword.set(''); + this.code.set(''); + this.dialog.set(dialog); + } + + protected close(): void { + if (this.security.signInAgain()) return this.dialog.set({ kind: 'signInAgain' }); + if (this.security.setup()) this.security.cancelSetup(); + this.dialog.set(null); + this.security.clearError(); } - protected proof(): { currentPassword?: string; code?: string } { - const value = this.proofValue().trim(); - if (!value) return {}; - return this.security.twoFactor()?.enabled ? { code: value } : { currentPassword: value }; + protected providerOf(dialog: Dialog | null): ProviderRow | null { + return dialog && 'provider' in dialog ? dialog.provider : null; } - private readonly passwordModel = signal({ currentPassword: '', newPassword: '' }); - protected readonly passwordForm = form( - this.passwordModel, - (path) => { - required(path.currentPassword); - required(path.newPassword); - minLength(path.newPassword, 8); - }, - { - submission: { - action: async () => { - const { currentPassword, newPassword } = this.passwordModel(); - await this.security.changePassword(currentPassword, newPassword); - this.passwordModel.set({ currentPassword: '', newPassword: '' }); - return undefined; - }, - }, - }, - ); - private readonly beginModel = signal({ currentPassword: '' }); - protected readonly beginForm = form(this.beginModel, (path) => required(path.currentPassword), { - submission: { - action: async () => { - await this.security.beginTwoFactor(this.beginModel().currentPassword); - this.beginModel.set({ currentPassword: '' }); - return undefined; - }, - }, - }); + protected sessionOf(dialog: Dialog | null): SessionSummary | null { + return dialog?.kind === 'signOut' ? dialog.session : null; + } + + protected unlink(provider: ProviderRow): void { + this.open(this.waysIn() <= 1 ? { kind: 'lastWayIn', provider } : { kind: 'unlink', provider }); + } + + private password(): string | null { + return this.proof()?.mode() === 'password' ? this.currentPassword() : null; + } + + private signInProof(): SignInProof { + if (this.twoFactorOn()) return { code: this.code().trim() }; + const password = this.password(); + return password ? { currentPassword: password } : {}; + } - private readonly confirmModel = signal({ code: '' }); - protected readonly confirmForm = form( - this.confirmModel, - (path) => { - required(path.code); - minLength(path.code, 6); - }, - { - submission: { - action: async () => { - await this.security.confirmTwoFactor(this.confirmModel().code.trim()); - this.confirmModel.set({ code: '' }); - return undefined; - }, - }, - }, - ); + protected async submitPassword(event: Event): Promise { + event.preventDefault(); + await this.security.setPassword(this.user.hasPassword() ? this.currentPassword() : null, this.newPassword()); + } - private readonly disableModel = signal({ password: '', proof: '' }); - protected readonly disableForm = form( - this.disableModel, - (path) => { - required(path.password); - required(path.proof); - }, - { - submission: { - action: async () => { - await this.security.disableTwoFactor( - this.disableModel().proof.trim(), - this.disableModel().password, - ); - this.disableModel.set({ password: '', proof: '' }); - return undefined; - }, - }, - }, - ); + protected async beginTwoFactor(event: Event): Promise { + event.preventDefault(); + await this.security.beginTwoFactor(this.password()); + } + + protected async confirmTwoFactor(event: Event): Promise { + event.preventDefault(); + await this.security.confirmTwoFactor(this.code().replace(/\s/g, '')); + this.code.set(''); + } + + protected savedCodes(): void { + this.security.finishTwoFactor(); + } + + protected async disableTwoFactor(event: Event): Promise { + event.preventDefault(); + await this.security.disableTwoFactor(this.code().trim(), this.password()); + } + + protected async link(event: Event, provider: ProviderRow): Promise { + event.preventDefault(); + await this.security.linkProvider(provider.key, this.signInProof()); + } + + protected async unlinkConfirmed(event: Event, provider: ProviderRow): Promise { + event.preventDefault(); + await this.security.unlinkProvider(provider.key, this.signInProof()); + } + + protected async signOut(session: SessionSummary): Promise { + if (await this.security.signOutSession(session.id)) { + this.dialog.set(null); + toast.success(`Signed out ${this.deviceName(session)}`); + } + } + + protected async copy(codes: string[]): Promise { + try { + await navigator.clipboard.writeText(codes.join('\n')); + toast.success('Recovery codes copied'); + } catch { + toast.error('Could not copy. Select the codes and copy them yourself.'); + } + } + + protected deviceName(session: SessionSummary): string { + return describeAgent(session.userAgent); + } + + protected when(value: string): string { + return formatModified(value); + } } diff --git a/src/Schuly.Frontend/src/app/stores/auth.store.ts b/src/Schuly.Frontend/src/app/stores/auth.store.ts index 310b8ad6..d7f43af5 100644 --- a/src/Schuly.Frontend/src/app/stores/auth.store.ts +++ b/src/Schuly.Frontend/src/app/stores/auth.store.ts @@ -6,6 +6,9 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { Observable, catchError, finalize, firstValueFrom, from, map, of, shareReplay } from 'rxjs'; +import { clearSignedIn, markSignedIn } from '../core/signed-in-at'; +import { beginExternalSignIn } from '../core/external-sign-in'; +import { rememberReturnUrl } from '../core/return-url'; import { AuthApi, SignInResponse, TokenResponse, isChallenge, isEmailVerification } from '../core/auth-api'; export type AuthStatus = 'unknown' | 'signedIn' | 'signedOut'; @@ -31,7 +34,8 @@ export const AuthStore = signalStore( withMethods((store, api = inject(AuthApi), router = inject(Router), http = inject(HttpClient)) => { let refreshing: Observable | null = null; - const accept = (tokens: TokenResponse) => { + const accept = (tokens: TokenResponse, signedIn = true) => { + if (signedIn) markSignedIn(); saveSession(tokens); patchState(store, { accessToken: tokens.access_token, status: 'signedIn', challenge: null, emailChallenge: null }); }; @@ -58,13 +62,14 @@ export const AuthStore = signalStore( swrCache.clear(); void clearBrowserCaches(); saveSession(null); + clearSignedIn(); patchState(store, { accessToken: null, status: 'signedOut', challenge: null, emailChallenge: null }); }; const refresh = (): Observable => { refreshing ??= exclusive(() => firstValueFrom(api.refresh())).pipe( map((tokens) => { - accept(tokens); + accept(tokens, false); return tokens.access_token; }), catchError(() => { @@ -138,6 +143,13 @@ export const AuthStore = signalStore( clear(); void router.navigate(['/login'], { queryParams: { changed: 1 } }); }, + /** Proves it's you for an account without a password: a fresh sign-in at a linked provider, which comes back to `returnPath`. */ + async reauthenticate(provider: string, returnPath: string): Promise { + await firstValueFrom(api.logout()).catch(() => undefined); + clear(); + rememberReturnUrl(returnPath); + await beginExternalSignIn(provider, '/login'); + }, }; }), ); diff --git a/src/Schuly.Frontend/src/app/stores/security.store.ts b/src/Schuly.Frontend/src/app/stores/security.store.ts index ba0bdde1..10f1c9b4 100644 --- a/src/Schuly.Frontend/src/app/stores/security.store.ts +++ b/src/Schuly.Frontend/src/app/stores/security.store.ts @@ -1,8 +1,8 @@ -import { inject } from '@angular/core'; -import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { computed, inject } from '@angular/core'; +import { patchState, signalStore, withComputed, withHooks, withMethods, withState } from '@ngrx/signals'; import { firstValueFrom } from 'rxjs'; import { toDataURL } from 'qrcode'; -import { AuthApi, LinkedSignIn, SignInProof, SignInProvider, TrustedDevice, TwoFactorStatus } from '../core/auth-api'; +import { AuthApi, LinkedSignIn, SessionSummary, SignInProof, SignInProvider, TrustedDevice, TwoFactorStatus } from '../core/auth-api'; import { externalError, newLinkState, returnUrl, takePending } from '../core/external-sign-in'; import { AuthStore } from './auth.store'; import { authErrorMessage } from './auth.store'; @@ -10,20 +10,30 @@ import { authErrorMessage } from './auth.store'; interface SecurityState { twoFactor: TwoFactorStatus | null; devices: TrustedDevice[]; + sessions: SessionSummary[]; providers: SignInProvider[]; linked: LinkedSignIn[]; setup: { secret: string; qr: string } | null; recoveryCodes: string[] | null; busy: boolean; + /** The last action's failure, shown in the dialog that started it. */ error: string | null; notice: string | null; signInAgain: string | null; } +export interface ProviderRow { + key: string; + name: string; + iconUrl: string | null; +} + +/** Password, second factor, sign-in providers and sessions. Every action reports success, so the dialog that started it knows whether to close. */ export const SecurityStore = signalStore( withState({ twoFactor: null, devices: [], + sessions: [], providers: [], linked: [], setup: null, @@ -33,59 +43,72 @@ export const SecurityStore = signalStore( notice: null, signInAgain: null, }), + withComputed(({ providers, linked }) => ({ + /** Linked providers, including one no longer configured, which can still be unlinked. */ + linkedRows: computed(() => + linked().map((login) => { + const configured = providers().find((p) => p.key === login.provider); + return { key: login.provider, name: configured?.display_name ?? login.display_name ?? login.provider, iconUrl: configured?.icon_url ?? null }; + }), + ), + unlinkedRows: computed(() => + providers() + .filter((p) => !linked().some((login) => login.provider === p.key)) + .map((p) => ({ key: p.key, name: p.display_name, iconUrl: p.icon_url ?? null })), + ), + })), withMethods((store, api = inject(AuthApi), auth = inject(AuthStore)) => { - const run = async (action: () => Promise, fallback: string) => { + const run = async (action: () => Promise, fallback: string): Promise => { patchState(store, { busy: true, error: null, notice: null }); try { await action(); + return true; } catch (error) { patchState(store, { error: authErrorMessage(error, fallback) }); + return false; } finally { patchState(store, { busy: false }); } }; const load = async () => { - const [twoFactor, devices, providers] = await Promise.all([ + const [twoFactor, devices, sessions, providers] = await Promise.all([ firstValueFrom(api.twoFactorStatus()), - firstValueFrom(api.trustedDevices()), + firstValueFrom(api.trustedDevices()).catch(() => []), + firstValueFrom(api.sessions()).catch(() => []), firstValueFrom(api.providers()).catch(() => []), ]); - const linked = providers.length ? await firstValueFrom(api.linkedSignIns()) : []; - patchState(store, { twoFactor, devices, providers, linked }); + // Without configured providers the server doesn't map the external endpoints at all. + const linked = providers.length ? await firstValueFrom(api.linkedSignIns()).catch(() => []) : []; + patchState(store, { twoFactor, devices, sessions, providers, linked }); }; return { load: () => run(load, 'Could not load your security settings.'), - changePassword: (currentPassword: string, newPassword: string) => + clearError: () => patchState(store, { error: null }), + /** `currentPassword` null sets a first password. */ + setPassword: (currentPassword: string | null, newPassword: string) => run(async () => { await firstValueFrom(api.changePassword(currentPassword, newPassword)); - patchState(store, { - signInAgain: 'Your password is changed. Sign in again with the new one.', - }); + patchState(store, { signInAgain: currentPassword === null ? 'Your password is added. Sign in again.' : 'Your password is changed. Sign in again with the new one.' }); }, 'That did not work. Check your current password.'), - resendVerification: (email: string, currentPassword: string) => + requestEmailChange: (email: string, currentPassword: string) => run(async () => { await firstValueFrom(api.requestEmailVerification(email, currentPassword)); - patchState(store, { notice: `We sent a confirmation link to ${email}.` }); + patchState(store, { notice: `We sent a confirmation to ${email}. Your email changes once you confirm it.` }); }, 'That did not work. Check your password.'), - beginTwoFactor: (currentPassword: string) => + beginTwoFactor: (currentPassword: string | null) => run(async () => { const { secret, uri } = await firstValueFrom(api.beginTwoFactor(currentPassword)); - patchState(store, { - setup: { secret, qr: await toDataURL(uri, { margin: 1, width: 200 }) }, - }); + patchState(store, { setup: { secret, qr: await toDataURL(uri, { margin: 1, width: 200 }) } }); }, 'That did not work. Check your password.'), confirmTwoFactor: (code: string) => run(async () => { const { recoveryCodes } = await firstValueFrom(api.confirmTwoFactor(code)); - patchState(store, { - setup: null, - recoveryCodes, - signInAgain: - 'Two-factor authentication is on. Sign in again with a code from your app.', - }); + patchState(store, { setup: null, recoveryCodes }); }, "That code didn't match. Try the current one from your app."), - disableTwoFactor: (proof: string, currentPassword: string) => + /** After the recovery codes are saved: turning the second factor on ended every session. */ + finishTwoFactor: () => patchState(store, { recoveryCodes: null, signInAgain: 'Two-factor authentication is on. Sign in again with a code from your app.' }), + disableTwoFactor: (proof: string, currentPassword: string | null) => run(async () => { await firstValueFrom(api.disableTwoFactor(proof, currentPassword)); patchState(store, { signInAgain: 'Two-factor authentication is off. Sign in again.' }); @@ -99,12 +122,17 @@ export const SecurityStore = signalStore( run(async () => { await firstValueFrom(api.unlinkProvider(provider, proof)); patchState(store, { signInAgain: 'The provider is unlinked. Sign in again.' }); - }, "That did not work. Check your code or password, and keep at least one way to sign in."), + }, 'That did not work. Check your code or password, and keep at least one way to sign in.'), linkFinished: (linked: string | null, failure: string | null, state: string | null) => { const pending = takePending(state); if (failure) patchState(store, { error: externalError(failure) }); else if (linked && pending) patchState(store, { notice: 'The provider is linked. You can now sign in with it.' }); }, + signOutSession: (id: string) => + run(async () => { + await firstValueFrom(api.revokeSession(id)); + patchState(store, { sessions: store.sessions().filter((s) => s.id !== id) }); + }, 'Could not sign out that device.'), forgetDevices: () => run(async () => { await firstValueFrom(api.forgetAllDevices()); @@ -112,13 +140,7 @@ export const SecurityStore = signalStore( }, 'Could not forget your devices.'), cancelSetup: () => patchState(store, { setup: null, error: null }), continueToSignIn: () => { - patchState(store, { - recoveryCodes: null, - signInAgain: null, - setup: null, - twoFactor: null, - devices: [], - }); + patchState(store, { recoveryCodes: null, signInAgain: null, setup: null, twoFactor: null, devices: [], sessions: [] }); auth.signInAgain(); }, }; diff --git a/src/Schuly.Frontend/src/app/stores/user.store.ts b/src/Schuly.Frontend/src/app/stores/user.store.ts index a3c96e3b..736f2eaa 100644 --- a/src/Schuly.Frontend/src/app/stores/user.store.ts +++ b/src/Schuly.Frontend/src/app/stores/user.store.ts @@ -38,6 +38,8 @@ export const UserStore = signalStore( return { name, email: computed(() => user()?.email ?? ''), + hasPassword: computed(() => user()?.hasPassword ?? true), + emailConfirmed: computed(() => user()?.emailConfirmed ?? false), avatar: computed(() => user()?.profilePictureUrl ?? null), initials: computed(() => initials(name())), schoolCount: computed(() => user()?.schoolUsers?.length ?? 0), diff --git a/src/Schuly.Tests/SecurityHardeningTests.cs b/src/Schuly.Tests/SecurityHardeningTests.cs index 959cc0f8..6f46073c 100644 --- a/src/Schuly.Tests/SecurityHardeningTests.cs +++ b/src/Schuly.Tests/SecurityHardeningTests.cs @@ -78,9 +78,25 @@ private sealed class PlainHasher : IPasswordHasher public PasswordVerificationResult Verify(string password, string hash) => password == hash ? PasswordVerificationResult.Succeeded : PasswordVerificationResult.Failed; } - private static AccountPasswordCheck Check(ServiceProvider provider, ToamaisutaaPasswordCredential? credential) => new(new OneCredential(credential), new PlainHasher(), Options.Create(new ToamaisutaaLocalLoginOptions()), new SecurityAuditLog(provider.GetRequiredService())); + /// Only the live-family lookup is reachable from the check; anything else it calls is a bug. + private sealed class OneSession(Guid familyId, DateTimeOffset startedAt) : IRefreshTokenStore + { + public Task FindLiveByFamilyAsync(Guid familyId1, CancellationToken cancellationToken = default) => Task.FromResult(familyId1 == familyId ? new ToamaisutaaRefreshToken { FamilyId = familyId, FamilyStartedAt = startedAt } : null); + public Task FindByHashAsync(string tokenHash, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CreateAsync(ToamaisutaaRefreshToken token, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task MarkRotatedAsync(Guid tokenId, DateTimeOffset rotatedAt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RevokeFamilyAsync(Guid familyId1, string reason, DateTimeOffset revokedAt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RevokeAllForUserAsync(Guid userId, string reason, DateTimeOffset revokedAt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ListActiveAsync(Guid userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task UpdateSecondFactorAsync(Guid familyId1, string authenticationMethods, string twoFactorSource, DateTimeOffset secondFactorAt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteExpiredAsync(DateTimeOffset expiredBefore, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private static readonly Guid Family = Guid.NewGuid(); + + private static AccountPasswordCheck Check(ServiceProvider provider, ToamaisutaaPasswordCredential? credential, DateTimeOffset? sessionStartedAt = null) => new(new OneCredential(credential), new PlainHasher(), new OneSession(Family, sessionStartedAt ?? DateTimeOffset.UtcNow.AddDays(-1)), Options.Create(new ToamaisutaaLocalLoginOptions()), Options.Create(new ToamaisutaaExternalLoginOptions()), new SecurityAuditLog(provider.GetRequiredService()), TimeProvider.System); - private static ClaimsPrincipal Subject(Guid id) => new(new ClaimsIdentity([new Claim("sub", id.ToString())], "test")); + private static ClaimsPrincipal Subject(Guid id, params Claim[] extra) => new(new ClaimsIdentity([new Claim("sub", id.ToString()), new Claim(ToamaisutaaDefaults.SessionIdClaim, Family.ToString()), .. extra], "test")); private static ToamaisutaaPasswordCredential Credential(Guid userId, DateTimeOffset? lockedUntil = null) => new() { UserId = userId, PasswordHash = "right", LockedOutUntil = lockedUntil }; @@ -113,11 +129,43 @@ public async Task Locked_account_is_refused_even_with_the_right_password() } [Test] - public async Task Single_sign_on_account_has_no_password_to_ask_for() + public async Task Single_sign_on_account_passes_with_a_session_started_moments_ago() + { + using var provider = NewDb(); + + await Assert.That(await Check(provider, null, DateTimeOffset.UtcNow.AddMinutes(-1)).RefusalAsync(Subject(Guid.NewGuid()), null)).IsNull(); + } + + [Test] + public async Task Single_sign_on_account_passes_with_a_fresh_second_factor_on_an_old_session() + { + using var provider = NewDb(); + var fresh = new Claim(ToamaisutaaDefaults.SecondFactorAtClaim, DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds().ToString()); + + await Assert.That(await Check(provider, null).RefusalAsync(Subject(Guid.NewGuid(), fresh), null)).IsNull(); + } + + [Test] + public async Task Single_sign_on_account_with_an_old_session_is_refused_and_recorded() + { + using var provider = NewDb(); + var id = Guid.NewGuid(); + + await Assert.That(await Check(provider, null).RefusalAsync(Subject(id), null)).IsNotNull(); + + using var scope = provider.CreateScope(); + var rows = await scope.ServiceProvider.GetRequiredService().SecurityAuditEntries.ToListAsync(); + await Assert.That(rows.Count).IsEqualTo(1); + await Assert.That(rows[0].Kind).IsEqualTo(AccountPasswordCheck.RefusedKind); + } + + [Test] + public async Task Single_sign_on_account_without_a_session_id_is_refused() { using var provider = NewDb(); + var bare = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", Guid.NewGuid().ToString())], "test")); - await Assert.That(await Check(provider, null).RefusalAsync(Subject(Guid.NewGuid()), null)).IsNull(); + await Assert.That(await Check(provider, null, DateTimeOffset.UtcNow.AddMinutes(-1)).RefusalAsync(bare, null)).IsNotNull(); } [Test] From 5af55aaff59b815426e88c55baf91403b6042b00 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:16:16 +0100 Subject: [PATCH 2/5] Require a confirmed email before an account can be used The confirm step can now correct a mistyped address, provider sign-ups need an address the provider verified, sign-in errors are visible again, and the profile drops the "Not confirmed" label. --- .../Extensions/AuthenticationExtensions.cs | 1 + src/Schuly.API/SignUp/VerifiedSignUpEmail.cs | 24 +++++++++ .../Queries/User/GetCurrentUserQuery.cs | 9 ++-- src/Schuly.Frontend/src/app/core/auth-api.ts | 5 ++ .../src/app/core/external-sign-in.ts | 2 +- .../src/app/pages/auth/confirm-email.ts | 54 ++++++++++++++++++- .../src/app/pages/auth/login.ts | 4 +- .../src/app/pages/auth/register.ts | 2 +- .../src/app/pages/settings/account-card.ts | 10 ++-- .../src/app/stores/auth.store.ts | 5 ++ src/Schuly.Tests/VerifiedSignUpEmailTests.cs | 35 ++++++++++++ 11 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 src/Schuly.API/SignUp/VerifiedSignUpEmail.cs create mode 100644 src/Schuly.Tests/VerifiedSignUpEmailTests.cs diff --git a/src/Schuly.API/Extensions/AuthenticationExtensions.cs b/src/Schuly.API/Extensions/AuthenticationExtensions.cs index 421f6397..e85c40c6 100644 --- a/src/Schuly.API/Extensions/AuthenticationExtensions.cs +++ b/src/Schuly.API/Extensions/AuthenticationExtensions.cs @@ -27,6 +27,7 @@ public static IServiceCollection AddSchulyAuthentication(this IServiceCollection services.AddToamaisutaaPasswordLogin(configuration); services.AddToamaisutaaTwoFactor(configuration); services.AddToamaisutaaTrustedDevices(configuration); + VerifiedSignUpEmail.Apply(configuration); services.AddToamaisutaaExternalLogin(configuration); services.PostConfigure(options => { diff --git a/src/Schuly.API/SignUp/VerifiedSignUpEmail.cs b/src/Schuly.API/SignUp/VerifiedSignUpEmail.cs new file mode 100644 index 00000000..6de03717 --- /dev/null +++ b/src/Schuly.API/SignUp/VerifiedSignUpEmail.cs @@ -0,0 +1,24 @@ +namespace Schuly.API.SignUp +{ + /// + /// A Schuly account has to have a confirmed email before it can be used. An account made by signing up through a provider has no local address to confirm with a code, so + /// the provider's email_verified is the proof: every configured provider gets RequireVerifiedEmailForSignUp unless the hoster set it. Set through + /// configuration rather than the options type, so this compiles against a Toamaisutaa that predates the option and simply does nothing there. + /// + public static class VerifiedSignUpEmail + { + public const string Setting = "RequireVerifiedEmailForSignUp"; + + /// The keys to add: one per provider that doesn't say for itself. + public static Dictionary Defaults(IConfiguration configuration) => + configuration.GetSection("ExternalLogin:Providers").GetChildren() + .Where(provider => provider[Setting] is null) + .ToDictionary(provider => $"{provider.Path}:{Setting}", _ => (string?)"true"); + + public static void Apply(IConfiguration configuration) + { + if (configuration is IConfigurationBuilder builder && Defaults(configuration) is { Count: > 0 } defaults) + builder.AddInMemoryCollection(defaults); + } + } +} diff --git a/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs b/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs index 31de626f..1510053d 100644 --- a/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs +++ b/src/Schuly.Application/Queries/User/GetCurrentUserQuery.cs @@ -13,7 +13,7 @@ namespace Schuly.Application.Queries.User [AllowAuthenticated] public record GetCurrentUserQuery() : IQuery>; - public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner, IPasswordCredentialStore credentials) : IQueryHandler> + public class GetCurrentUserQueryHandler(IUserService userService, SchulyDbContext dbContext, IAvatarUrlSigner avatarUrlSigner, IPasswordCredentialStore credentials, IExternalLoginStore externalLogins) : IQueryHandler> { public async ValueTask> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken) { @@ -28,9 +28,12 @@ public async ValueTask> Handle(GetCurrentUserQuery qu var dto = user.ToDto(); dto.ProfilePictureUrl = avatarUrlSigner.ToPublicUserUrl(user.Id, user.ProfilePictureUrl); - var credential = Guid.TryParse(user.ExternalId, out var loginId) ? await credentials.FindByUserIdAsync(loginId, cancellationToken) : null; + var hasLogin = Guid.TryParse(user.ExternalId, out var loginId); + var credential = hasLogin ? await credentials.FindByUserIdAsync(loginId, cancellationToken) : null; dto.HasPassword = credential is not null; - dto.EmailConfirmed = credential?.EmailConfirmedAt is not null && string.Equals(credential.Email, user.Email, StringComparison.OrdinalIgnoreCase); + // Confirmed here by code, or by a provider that said it verified this address. + dto.EmailConfirmed = (credential?.EmailConfirmedAt is not null && string.Equals(credential.Email, user.Email, StringComparison.OrdinalIgnoreCase)) + || (hasLogin && (await externalLogins.ListAsync(loginId, cancellationToken)).Any(login => login.EmailVerified && string.Equals(login.Email, user.Email, StringComparison.OrdinalIgnoreCase))); return Result.Success(dto); } } diff --git a/src/Schuly.Frontend/src/app/core/auth-api.ts b/src/Schuly.Frontend/src/app/core/auth-api.ts index ac13df64..84e5bf83 100644 --- a/src/Schuly.Frontend/src/app/core/auth-api.ts +++ b/src/Schuly.Frontend/src/app/core/auth-api.ts @@ -125,6 +125,11 @@ export class AuthApi { ); } + /** For a mistyped address: moves the pending confirmation to `newEmail` and answers with a new challenge. */ + changeEmailCodeAddress(challenge: string, newEmail: string): Observable { + return this.http.post('/auth/email/change-address', { challenge, newEmail }); + } + resendEmailCode(challenge: string): Observable { return this.http.post('/auth/email/resend-code', { challenge }); } diff --git a/src/Schuly.Frontend/src/app/core/external-sign-in.ts b/src/Schuly.Frontend/src/app/core/external-sign-in.ts index 618b1a28..1f2ded12 100644 --- a/src/Schuly.Frontend/src/app/core/external-sign-in.ts +++ b/src/Schuly.Frontend/src/app/core/external-sign-in.ts @@ -7,7 +7,7 @@ interface Pending { state: string; } -const EXTERNAL_ERRORS = new Set(['access_denied', 'account_not_found', 'account_exists', 'linked_to_another_account', 'provider_already_linked', 'external_login_failed']); +const EXTERNAL_ERRORS = new Set(['access_denied', 'account_not_found', 'account_exists', 'linked_to_another_account', 'provider_already_linked', 'email_not_verified', 'external_login_failed']); /** The message for an external sign-in error code; unknown codes read as a plain failure. */ export const externalError = (code?: string | null): string => t(`auth.external.${code && EXTERNAL_ERRORS.has(code) ? code : 'external_login_failed'}`); diff --git a/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts b/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts index 0790e7d6..38de11e9 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts @@ -8,6 +8,7 @@ import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; import { HlmFieldImports } from '@spartan-ng/helm/field'; import { HlmInputOtpImports } from '@spartan-ng/helm/input-otp'; +import { HlmInput } from '@spartan-ng/helm/input'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; import { AuthStore, EmailChallenge, errorCode } from '../../stores/auth.store'; @@ -32,6 +33,7 @@ export function confirmEmailHint(challenge: EmailChallenge | null): string { HlmCardImports, HlmFieldImports, HlmInputOtpImports, + HlmInput, HlmSpinner, TranslatePipe, ], @@ -62,7 +64,7 @@ export function confirmEmailHint(challenge: EmailChallenge | null): string { }
@if (error()) { - {{ error() }} + {{ error() }} } @else if (notice()) {

{{ notice() }}

} @@ -88,6 +90,26 @@ export function confirmEmailHint(challenge: EmailChallenge | null): string { {{ cooldown() > 0 ? ('auth.confirm.resendIn' | translate: { seconds: cooldown() }) : ('auth.confirm.resend' | translate) }} + @if (changing()) { +
+
+ + +

We send a new code there. The old one stops working.

+
+
+ + +
+
+ } @else { + + }
@@ -107,6 +129,9 @@ export class ConfirmEmail { protected readonly notice = signal(null); protected readonly resending = signal(false); protected readonly cooldown = signal(0); + protected readonly changing = signal(false); + protected readonly newAddress = signal(''); + protected readonly busy = signal(false); private timer: ReturnType | null = null; @@ -161,6 +186,33 @@ export class ConfirmEmail { } } + protected async changeAddress(event: Event): Promise { + event.preventDefault(); + this.error.set(null); + this.notice.set(null); + this.busy.set(true); + try { + await this.auth.changeEmailCodeAddress(this.newAddress().trim()); + this.model.set({ code: '' }); + this.changing.set(false); + this.notice.set('We sent a new code to your new address.'); + document.getElementById('email-code')?.focus(); + } catch (error) { + const status = error instanceof HttpErrorResponse ? error.status : 0; + this.error.set( + status === 409 + ? 'Another account already uses that email address.' + : status === 400 + ? 'Enter a valid email address.' + : status === 401 + ? 'This confirmation is no longer valid. Go back to sign in and start again.' + : 'Something went wrong. Try again in a moment.', + ); + } finally { + this.busy.set(false); + } + } + protected back(): void { this.stopTimer(); this.auth.cancelEmailChallenge(); diff --git a/src/Schuly.Frontend/src/app/pages/auth/login.ts b/src/Schuly.Frontend/src/app/pages/auth/login.ts index 767cc45c..4bb0950a 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/login.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/login.ts @@ -113,7 +113,7 @@ const CODE_LENGTH = 6; @if (error()) { - {{ error() }} + {{ error() }} } From e07bf118f15211bb0d8f4a1415f0753056bd0dba Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:41:11 +0100 Subject: [PATCH 4/5] Share one password field, translate Security & accounts, and use the app's select for the language The show/hide toggle sits beside the input so password managers keep the input's right edge, every password form names its account for them, and the new settings strings are in all five languages. --- src/Schuly.Frontend/public/i18n/de.json | 214 +++++++++++++++++- src/Schuly.Frontend/public/i18n/en.json | 214 +++++++++++++++++- src/Schuly.Frontend/public/i18n/fr.json | 214 +++++++++++++++++- src/Schuly.Frontend/public/i18n/it.json | 214 +++++++++++++++++- src/Schuly.Frontend/public/i18n/rm.json | 214 +++++++++++++++++- .../src/app/core/signed-in-at.ts | 17 +- .../src/app/pages/auth/auth-layout.ts | 2 +- .../src/app/pages/auth/confirm-email.ts | 20 +- .../src/app/pages/auth/login.ts | 32 +-- .../src/app/pages/auth/register.ts | 10 +- .../src/app/pages/auth/reset-password.ts | 12 +- .../src/app/pages/settings/account-card.ts | 16 +- .../src/app/pages/settings/accounts-card.ts | 52 +++-- .../app/pages/settings/danger-zone-card.ts | 83 +++---- .../src/app/pages/settings/proof.ts | 42 ++-- .../src/app/pages/settings/security-card.ts | 195 ++++++++-------- .../src/app/shared/language-select.ts | 44 ++-- .../src/app/shared/link-step.ts | 14 +- .../src/app/shared/password-field.ts | 72 ++++++ .../src/app/stores/security.store.ts | 33 +-- 20 files changed, 1428 insertions(+), 286 deletions(-) create mode 100644 src/Schuly.Frontend/src/app/shared/password-field.ts diff --git a/src/Schuly.Frontend/public/i18n/de.json b/src/Schuly.Frontend/public/i18n/de.json index 819c19d8..61229bbf 100644 --- a/src/Schuly.Frontend/public/i18n/de.json +++ b/src/Schuly.Frontend/public/i18n/de.json @@ -291,7 +291,14 @@ "invalid": "Diese Bestätigung ist nicht mehr gültig. Geh zurück zur Anmeldung und beginne neu.", "sendFailed": "Beim Senden des Codes ist etwas schiefgelaufen. Versuch es gleich nochmals.", "mismatch": "Der Code stimmt nicht. Prüf die E-Mail und versuch es nochmals.", - "expired": "Dieser Code ist abgelaufen. Wir können dir einen neuen schicken." + "expired": "Dieser Code ist abgelaufen. Wir können dir einen neuen schicken.", + "changeAddress": "Falsche Adresse? E-Mail ändern", + "newAddress": "Deine E-Mail-Adresse", + "newAddressHint": "Wir schicken dir dorthin einen neuen Code. Der alte gilt dann nicht mehr.", + "sendCode": "Code senden", + "sentToNew": "Wir haben dir einen neuen Code an die neue Adresse geschickt.", + "addressTaken": "Diese E-Mail-Adresse nutzt schon ein anderes Konto.", + "addressInvalid": "Gib eine gültige E-Mail-Adresse ein." }, "verify": "Bestätigen", "backToSignIn": "Zurück zur Anmeldung", @@ -338,7 +345,8 @@ "account_exists": "Ein Konto mit dieser E-Mail gibt es schon. Melde dich mit deinem Passwort an und verknüpfe den Anbieter dann in den Einstellungen.", "linked_to_another_account": "Diese Anmeldung gehört schon zu einem anderen Schuly-Konto.", "provider_already_linked": "Dieser Anbieter ist schon mit deinem Konto verknüpft.", - "external_login_failed": "Die Anmeldung mit diesem Anbieter hat nicht geklappt. Versuch es nochmals." + "external_login_failed": "Die Anmeldung mit diesem Anbieter hat nicht geklappt. Versuch es nochmals.", + "email_not_verified": "Dieser Anbieter hat deine E-Mail-Adresse nicht bestätigt, darum wurde kein Konto erstellt. Bestätige sie zuerst dort oder erstelle ein Konto mit deiner E-Mail." }, "tooMany": "Zu viele Versuche. Warte eine Minute und versuch es nochmals.", "unreachable": "Schuly ist gerade nicht erreichbar.", @@ -396,6 +404,208 @@ "confirmTitle": "Konto löschen?", "confirmDescription": "Damit löschst du dein Schuly-Konto, deine verknüpften Schulkonten und alle Noten, Prüfungen, Absenzen und Dokumente auf dem Server endgültig.", "password": "Dein Passwort (leer lassen, wenn du nur Single Sign-on nutzt)" + }, + "profile": { + "label": "Profil", + "changePicture": "Profilbild ändern", + "upload": "Bild hochladen…", + "confirmed": "Bestätigt", + "tooLarge": "Das Bild darf höchstens 2 MB gross sein." + }, + "accounts": { + "title": "Schulen & Konten", + "description": "Die Schulen und Dienste, die Schuly für dich synchronisiert.", + "add": "Hinzufügen", + "addAria": "Schule oder Konto hinzufügen", + "loading": "Verknüpfte Konten werden geladen", + "groupAria": "Konten bei {{name}}", + "loadFailed": "Die Konten konnten nicht geladen werden", + "noAnswer": "Dieser Dienst hat nicht geantwortet. Versuch es später nochmals.", + "status": { + "ok": "Verbunden", + "syncing": "Wird synchronisiert", + "expired": "Anmeldung abgelaufen", + "error": "Problem" + }, + "signInAgain": "Neu anmelden", + "signInAgainAria": "Bei {{name}} neu anmelden", + "reauth": "Erneut anmelden", + "reauthAria": "{{name}} erneut anmelden", + "unlink": "Trennen", + "unlinkAria": "{{name}} trennen", + "unlinkTitle": "{{name}} trennen?", + "unlinkText": "Schuly liest es nicht mehr und löscht, was davon gespeichert ist. Du kannst es jederzeit wieder hinzufügen.", + "none": "Noch keine Schulen oder Konten verknüpft. Füge eines hinzu, damit Schuly synchronisiert.", + "linked": "{{name}} verknüpft", + "accountLinked": "Konto verknüpft", + "finishFailed": "Die Anmeldung konnte nicht abgeschlossen werden.", + "unlinked": "{{name}} getrennt" + }, + "security": { + "title": "Sicherheit", + "description": "Wie du dich bei Schuly anmeldest und wo du angemeldet bist.", + "password": { + "label": "Passwort", + "set": "Gesetzt", + "notSet": "Nicht gesetzt · du meldest dich über einen Anbieter an", + "change": "Ändern", + "add": "Passwort hinzufügen" + }, + "twoFactor": { + "label": "Zwei-Faktor-Authentifizierung", + "on": "An · {count, plural, one {# Wiederherstellungscode übrig} other {# Wiederherstellungscodes übrig}}", + "off": "Aus · ein Code von deinem Handy bei jeder neuen Anmeldung", + "setUp": "Einrichten", + "turnOff": "Ausschalten" + }, + "providers": { + "title": "Anmeldeanbieter", + "link": "Verknüpfen", + "linkAria": "Anmeldeanbieter verknüpfen", + "unlink": "Trennen", + "unlinkAria": "{{name}} trennen", + "none": "Kein Anbieter verknüpft. Verknüpfe einen, um dich ohne Passwort anzumelden." + }, + "devices": { + "title": "Geräte", + "lastActive": "Zuletzt aktiv: {{when}}", + "thisDevice": "Dieses Gerät", + "signOut": "Abmelden", + "signOutAria": "{{name}} abmelden", + "signedOut": "{{name}} abgemeldet", + "remembered": "{count, plural, one {# Gerät überspringt} other {# Geräte überspringen}} den Zwei-Faktor-Code.", + "forget": "Gemerkte Geräte vergessen", + "unknown": "Unbekanntes Gerät", + "app": "Schuly-App", + "on": "{{browser}} auf {{system}}" + }, + "passwordDialog": { + "changeTitle": "Passwort ändern", + "addTitle": "Passwort hinzufügen", + "changeDescription": "Danach meldest du dich hier und auf deinen anderen Geräten mit dem neuen an.", + "addDescription": "Danach kannst du dich auch mit deiner E-Mail und diesem Passwort anmelden.", + "newPassword": "Neues Passwort", + "newPasswordHint": "Mindestens {{min}} Zeichen. Ein Satz, den du dir merken kannst, eignet sich gut.", + "submitChange": "Passwort ändern", + "submitAdd": "Passwort hinzufügen" + }, + "twoFactorDialog": { + "title": "Zwei-Faktor-Authentifizierung einrichten", + "step": "Schritt {{step}} von 3 · {{label}}", + "steps": { + "proof": "bestätige, dass du es bist", + "app": "App verbinden", + "codes": "Wiederherstellungscodes" + }, + "codesTitle": "Speichere deine Wiederherstellungscodes", + "codesText": "Jeder meldet dich einmal an, falls du dein Handy verlierst. Bewahre sie sicher auf, nicht nur auf diesem Gerät. Sie werden nur jetzt angezeigt.", + "codesAria": "Wiederherstellungscodes", + "copyCodes": "Codes kopieren", + "copied": "Wiederherstellungscodes kopiert", + "copyFailed": "Kopieren hat nicht geklappt. Markiere die Codes und kopiere sie selbst.", + "saved": "Ich habe sie gespeichert", + "scan": "Scanne das mit deiner Authenticator-App und gib dann den 6-stelligen Code ein, den sie anzeigt.", + "qrAlt": "QR-Code für deine Authenticator-App", + "cantScan": "Scannen geht nicht? Gib ein:", + "codeLabel": "Code aus der App", + "verify": "Bestätigen", + "appTitle": "Du brauchst eine Authenticator-App", + "appText": "Microsoft Authenticator, Google Authenticator oder deinen Passwortmanager." + }, + "twoFactorOff": { + "title": "Zwei-Faktor-Authentifizierung ausschalten", + "description": "Zum Anmelden brauchst du dann nur noch dein Passwort oder deinen Anbieter.", + "codeLabel": "Code aus deiner App oder ein Wiederherstellungscode" + }, + "linkPick": { + "title": "Anbieter verknüpfen", + "description": "Wähle einen, mit dem du dich zusätzlich anmelden willst.", + "linkAria": "{{name}} verknüpfen" + }, + "link": { + "title": "{{name}} verknüpfen", + "description": "Danach kannst du dich auch mit {{name}} anmelden.", + "codeLabel": "Code aus deiner App", + "submit": "Weiter zu {{name}}" + }, + "unlink": { + "title": "{{name}} trennen?", + "description": "Du kannst dich dann nicht mehr mit {{name}} anmelden und wirst überall abgemeldet, auch hier." + }, + "lastWayIn": { + "title": "Du kannst {{name}} noch nicht trennen", + "alertTitle": "Es ist deine einzige Anmeldemöglichkeit", + "alertText": "Füge zuerst ein Passwort hinzu oder verknüpfe einen anderen Anbieter, sonst sperrst du dich aus.", + "addPassword": "Passwort hinzufügen" + }, + "signOutDialog": { + "title": "{{name}} abmelden?", + "description": "Das Gerät muss sich neu anmelden, um Schuly zu nutzen." + }, + "signInAgain": { + "description": "Zu deiner Sicherheit beendet eine Änderung an deiner Anmeldung alle Sitzungen, auch diese.", + "button": "Neu anmelden" + }, + "messages": { + "loadFailed": "Deine Sicherheitseinstellungen konnten nicht geladen werden.", + "passwordAdded": "Dein Passwort ist hinzugefügt. Melde dich neu an.", + "passwordChanged": "Dein Passwort ist geändert. Melde dich mit dem neuen an.", + "checkCurrentPassword": "Das hat nicht geklappt. Prüfe dein aktuelles Passwort.", + "emailSent": "Wir haben eine Bestätigung an {{email}} geschickt. Deine E-Mail ändert sich, sobald du sie bestätigst.", + "checkPassword": "Das hat nicht geklappt. Prüfe dein Passwort.", + "codeMismatch": "Dieser Code stimmt nicht. Nimm den aktuellen aus deiner App.", + "twoFactorOn": "Die Zwei-Faktor-Authentifizierung ist an. Melde dich mit einem Code aus deiner App neu an.", + "twoFactorOff": "Die Zwei-Faktor-Authentifizierung ist aus. Melde dich neu an.", + "codeOrPassword": "Code oder Passwort stimmt nicht.", + "unlinked": "Der Anbieter ist getrennt. Melde dich neu an.", + "unlinkFailed": "Das hat nicht geklappt. Prüfe Code oder Passwort und behalte mindestens eine Anmeldemöglichkeit.", + "linked": "Der Anbieter ist verknüpft. Du kannst dich jetzt damit anmelden.", + "signOutFailed": "Dieses Gerät konnte nicht abgemeldet werden.", + "forgetFailed": "Deine Geräte konnten nicht vergessen werden." + } + }, + "proof": { + "currentPassword": "Aktuelles Passwort", + "continueWith": "Weiter mit {{name}}", + "signOutAndIn": "Melde dich ab und wieder an und versuch es dann nochmals.", + "reason": { + "addPassword": "Um ein Passwort hinzuzufügen, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "twoFactor": "Um die Zwei-Faktor-Authentifizierung einzuschalten, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "twoFactorOff": "Um die Zwei-Faktor-Authentifizierung auszuschalten, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "link": "Um {{name}} zu verknüpfen, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "unlink": "Um {{name}} zu trennen, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "email": "Um deine E-Mail zu ändern, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück.", + "delete": "Um dein Konto zu löschen, bestätige mit einer neuen Anmeldung, dass du es bist. Du kommst direkt hierher zurück." + } + }, + "danger": { + "title": "Gefahrenzone", + "description": "Änderungen am Konto selbst.", + "email": { + "label": "E-Mail ändern", + "text": "Wohin Schuly Codes und Hinweise schickt.", + "change": "Ändern", + "description": "Wir schicken einen Link an die neue Adresse; deine E-Mail ändert sich, sobald du ihn öffnest.", + "passwordFirst": "Füge zuerst ein Passwort hinzu", + "passwordFirstText": "Deine E-Mail hier gehört zum Anbieter, mit dem du dich anmeldest. Füge unter Sicherheit ein Passwort hinzu, dann kannst du deine eigene wählen.", + "checkInbox": "Schau in dein Postfach", + "sentTo": "Wir haben einen Link an {{email}} geschickt. Öffne ihn, um die Änderung abzuschliessen.", + "newEmail": "Neue E-Mail", + "submit": "Link senden" + }, + "export": { + "label": "Deine Daten exportieren", + "text": "Eine Datei mit deinem Profil, deinen verknüpften Konten und den synchronisierten Schuldaten.", + "button": "Exportieren" + }, + "delete": { + "text": "Entfernt dein Konto und alles, was Schuly gespeichert hat. Das lässt sich nicht rückgängig machen.", + "description": "Dein Profil, deine verknüpften Konten und alle synchronisierten Schuldaten werden gelöscht. Deine Schulkonten selbst bleiben unberührt.", + "alertTitle": "Das lässt sich nicht rückgängig machen", + "alertText": "Exportiere zuerst deine Daten, wenn du sie behalten willst.", + "typeConfirm": "Gib {{word}} ein, um zu bestätigen", + "word": "LÖSCHEN" + } } }, "chat": { diff --git a/src/Schuly.Frontend/public/i18n/en.json b/src/Schuly.Frontend/public/i18n/en.json index 95d4378e..9e95c141 100644 --- a/src/Schuly.Frontend/public/i18n/en.json +++ b/src/Schuly.Frontend/public/i18n/en.json @@ -291,7 +291,14 @@ "invalid": "This confirmation is no longer valid. Go back to sign in and start again.", "sendFailed": "Something went wrong sending the code. Try again in a moment.", "mismatch": "That code didn't match. Check the email and try again.", - "expired": "This code has expired. We can send you a new one." + "expired": "This code has expired. We can send you a new one.", + "changeAddress": "Wrong address? Change email", + "newAddress": "Your email address", + "newAddressHint": "We send a new code there. The old one stops working.", + "sendCode": "Send code", + "sentToNew": "We sent a new code to your new address.", + "addressTaken": "Another account already uses that email address.", + "addressInvalid": "Enter a valid email address." }, "verify": "Verify", "backToSignIn": "Back to sign in", @@ -338,7 +345,8 @@ "account_exists": "An account with that email already exists. Sign in with your password, then link this provider in Settings.", "linked_to_another_account": "That sign-in already belongs to another Schuly account.", "provider_already_linked": "That provider is already linked to your account.", - "external_login_failed": "Signing in with that provider did not work. Try again." + "external_login_failed": "Signing in with that provider did not work. Try again.", + "email_not_verified": "That provider hasn't confirmed your email address, so no account was created. Confirm it there first, or create an account with your email." }, "tooMany": "Too many attempts. Wait a minute and try again.", "unreachable": "Schuly is not reachable right now.", @@ -396,6 +404,208 @@ "confirmTitle": "Delete your account?", "confirmDescription": "This permanently deletes your Schuly account, your linked school accounts and every grade, exam, absence and document synced to the server.", "password": "Your password (leave empty if you only use single sign-on)" + }, + "profile": { + "label": "Profile", + "changePicture": "Change profile picture", + "upload": "Upload a picture…", + "confirmed": "Confirmed", + "tooLarge": "The picture must be at most 2 MB." + }, + "accounts": { + "title": "Schools & accounts", + "description": "The schools and services Schuly syncs for you.", + "add": "Add", + "addAria": "Add a school or account", + "loading": "Loading linked accounts", + "groupAria": "{{name}} accounts", + "loadFailed": "Could not load the accounts", + "noAnswer": "This service did not answer. Try again later.", + "status": { + "ok": "Connected", + "syncing": "Syncing", + "expired": "Sign-in expired", + "error": "Problem" + }, + "signInAgain": "Sign in again", + "signInAgainAria": "Sign in again to {{name}}", + "reauth": "Re-authenticate", + "reauthAria": "Re-authenticate {{name}}", + "unlink": "Unlink", + "unlinkAria": "Unlink {{name}}", + "unlinkTitle": "Unlink {{name}}?", + "unlinkText": "Schuly stops reading it and deletes what it stored from it. You can add it again any time.", + "none": "No schools or accounts linked yet. Add one to start syncing.", + "linked": "Linked {{name}}", + "accountLinked": "Account linked", + "finishFailed": "Could not finish the sign-in.", + "unlinked": "Unlinked {{name}}" + }, + "security": { + "title": "Security", + "description": "How you sign in to Schuly, and where you're signed in.", + "password": { + "label": "Password", + "set": "Set", + "notSet": "Not set · you sign in with a provider", + "change": "Change", + "add": "Add password" + }, + "twoFactor": { + "label": "Two-factor authentication", + "on": "On · {count, plural, one {# recovery code left} other {# recovery codes left}}", + "off": "Off · a code from your phone at each new sign-in", + "setUp": "Set up", + "turnOff": "Turn off" + }, + "providers": { + "title": "Sign-in providers", + "link": "Link", + "linkAria": "Link a sign-in provider", + "unlink": "Unlink", + "unlinkAria": "Unlink {{name}}", + "none": "No provider linked. Link one to sign in without your password." + }, + "devices": { + "title": "Devices", + "lastActive": "Last active: {{when}}", + "thisDevice": "This device", + "signOut": "Sign out", + "signOutAria": "Sign out {{name}}", + "signedOut": "Signed out {{name}}", + "remembered": "{count, plural, one {# device skips} other {# devices skip}} the two-factor code.", + "forget": "Forget remembered devices", + "unknown": "Unknown device", + "app": "Schuly app", + "on": "{{browser}} on {{system}}" + }, + "passwordDialog": { + "changeTitle": "Change password", + "addTitle": "Add a password", + "changeDescription": "Then sign in again with the new one, here and on your other devices.", + "addDescription": "Then you can also sign in with your email and this password.", + "newPassword": "New password", + "newPasswordHint": "At least {{min}} characters. A sentence you'll remember works well.", + "submitChange": "Change password", + "submitAdd": "Add password" + }, + "twoFactorDialog": { + "title": "Set up two-factor authentication", + "step": "Step {{step}} of 3 · {{label}}", + "steps": { + "proof": "confirm it's you", + "app": "connect your app", + "codes": "recovery codes" + }, + "codesTitle": "Save your recovery codes", + "codesText": "Each one signs you in once if you lose your phone. Keep them somewhere safe, not only on this device. They're only shown now.", + "codesAria": "Recovery codes", + "copyCodes": "Copy codes", + "copied": "Recovery codes copied", + "copyFailed": "Could not copy. Select the codes and copy them yourself.", + "saved": "I saved them", + "scan": "Scan this with your authenticator app, then type the 6-digit code it shows.", + "qrAlt": "QR code for your authenticator app", + "cantScan": "Can't scan? Enter", + "codeLabel": "Code from the app", + "verify": "Verify", + "appTitle": "You'll need an authenticator app", + "appText": "Microsoft Authenticator, Google Authenticator or your password manager." + }, + "twoFactorOff": { + "title": "Turn off two-factor authentication", + "description": "Signing in then needs only your password or provider.", + "codeLabel": "Code from your app or a recovery code" + }, + "linkPick": { + "title": "Link a provider", + "description": "Pick one to sign in with, as well as the ones you have.", + "linkAria": "Link {{name}}" + }, + "link": { + "title": "Link {{name}}", + "description": "Then you can sign in with {{name}} too.", + "codeLabel": "Code from your app", + "submit": "Continue to {{name}}" + }, + "unlink": { + "title": "Unlink {{name}}?", + "description": "You won't be able to sign in with {{name}} any more, and you'll be signed out everywhere, here too." + }, + "lastWayIn": { + "title": "You can't unlink {{name}} yet", + "alertTitle": "It's your only way to sign in", + "alertText": "Add a password or link another provider first, otherwise you'd lock yourself out.", + "addPassword": "Add a password" + }, + "signOutDialog": { + "title": "Sign out {{name}}?", + "description": "It has to sign in again to use Schuly." + }, + "signInAgain": { + "description": "For your safety, changing how you sign in ends every session, including this one.", + "button": "Sign in again" + }, + "messages": { + "loadFailed": "Could not load your security settings.", + "passwordAdded": "Your password is added. Sign in again.", + "passwordChanged": "Your password is changed. Sign in again with the new one.", + "checkCurrentPassword": "That did not work. Check your current password.", + "emailSent": "We sent a confirmation to {{email}}. Your email changes once you confirm it.", + "checkPassword": "That did not work. Check your password.", + "codeMismatch": "That code didn't match. Try the current one from your app.", + "twoFactorOn": "Two-factor authentication is on. Sign in again with a code from your app.", + "twoFactorOff": "Two-factor authentication is off. Sign in again.", + "codeOrPassword": "That code or password didn't match.", + "unlinked": "The provider is unlinked. Sign in again.", + "unlinkFailed": "That did not work. Check your code or password, and keep at least one way to sign in.", + "linked": "The provider is linked. You can now sign in with it.", + "signOutFailed": "Could not sign out that device.", + "forgetFailed": "Could not forget your devices." + } + }, + "proof": { + "currentPassword": "Current password", + "continueWith": "Continue with {{name}}", + "signOutAndIn": "Sign out and sign in again, then try once more.", + "reason": { + "addPassword": "To add a password, confirm it's you by signing in again. You come straight back here.", + "twoFactor": "To turn on two-factor authentication, confirm it's you by signing in again. You come straight back here.", + "twoFactorOff": "To turn off two-factor authentication, confirm it's you by signing in again. You come straight back here.", + "link": "To link {{name}}, confirm it's you by signing in again. You come straight back here.", + "unlink": "To unlink {{name}}, confirm it's you by signing in again. You come straight back here.", + "email": "To change your email, confirm it's you by signing in again. You come straight back here.", + "delete": "To delete your account, confirm it's you by signing in again. You come straight back here." + } + }, + "danger": { + "title": "Danger zone", + "description": "Changes to the account itself.", + "email": { + "label": "Change email", + "text": "Where Schuly sends codes and notices.", + "change": "Change", + "description": "We send a link to the new address; your email changes once you open it.", + "passwordFirst": "Add a password first", + "passwordFirstText": "Your email here belongs to the provider you sign in with. Add a password in Security, then you can choose your own.", + "checkInbox": "Check your inbox", + "sentTo": "We sent a link to {{email}}. Open it to finish the change.", + "newEmail": "New email", + "submit": "Send link" + }, + "export": { + "label": "Export your data", + "text": "A file with your profile, linked accounts and synced school data.", + "button": "Export" + }, + "delete": { + "text": "Removes your account and everything Schuly stored. This can't be undone.", + "description": "Your profile, linked accounts and all synced school data are deleted. Your school accounts themselves stay untouched.", + "alertTitle": "This can't be undone", + "alertText": "Export your data first if you want to keep it.", + "typeConfirm": "Type {{word}} to confirm", + "word": "DELETE" + } } }, "chat": { diff --git a/src/Schuly.Frontend/public/i18n/fr.json b/src/Schuly.Frontend/public/i18n/fr.json index c4c7eeea..ded0af31 100644 --- a/src/Schuly.Frontend/public/i18n/fr.json +++ b/src/Schuly.Frontend/public/i18n/fr.json @@ -291,7 +291,14 @@ "invalid": "Cette confirmation n'est plus valable. Retourne à la connexion et recommence.", "sendFailed": "L'envoi du code a échoué. Réessaie dans un instant.", "mismatch": "Ce code ne correspond pas. Vérifie l'e-mail et réessaie.", - "expired": "Ce code a expiré. Nous pouvons t'en envoyer un nouveau." + "expired": "Ce code a expiré. Nous pouvons t'en envoyer un nouveau.", + "changeAddress": "Mauvaise adresse ? Changer l’e-mail", + "newAddress": "Ton adresse e-mail", + "newAddressHint": "Nous y envoyons un nouveau code. L’ancien ne fonctionne plus.", + "sendCode": "Envoyer le code", + "sentToNew": "Nous avons envoyé un nouveau code à ta nouvelle adresse.", + "addressTaken": "Un autre compte utilise déjà cette adresse e-mail.", + "addressInvalid": "Saisis une adresse e-mail valide." }, "verify": "Vérifier", "backToSignIn": "Retour à la connexion", @@ -338,7 +345,8 @@ "account_exists": "Un compte avec cet e-mail existe déjà. Connecte-toi avec ton mot de passe, puis associe ce fournisseur dans les paramètres.", "linked_to_another_account": "Cette connexion appartient déjà à un autre compte Schuly.", "provider_already_linked": "Ce fournisseur est déjà associé à ton compte.", - "external_login_failed": "La connexion avec ce fournisseur n'a pas marché. Réessaie." + "external_login_failed": "La connexion avec ce fournisseur n'a pas marché. Réessaie.", + "email_not_verified": "Ce fournisseur n’a pas confirmé ton adresse e-mail, aucun compte n’a donc été créé. Confirme-la d’abord chez lui ou crée un compte avec ton e-mail." }, "tooMany": "Trop de tentatives. Attends une minute et réessaie.", "unreachable": "Schuly n'est pas joignable pour l'instant.", @@ -396,6 +404,208 @@ "confirmTitle": "Supprimer ton compte ?", "confirmDescription": "Cela supprime définitivement ton compte Schuly, tes comptes scolaires liés et toutes les notes, épreuves, absences et documents synchronisés sur le serveur.", "password": "Ton mot de passe (laisse vide si tu utilises uniquement l'authentification unique)" + }, + "profile": { + "label": "Profil", + "changePicture": "Changer la photo de profil", + "upload": "Téléverser une photo…", + "confirmed": "Confirmée", + "tooLarge": "La photo ne doit pas dépasser 2 Mo." + }, + "accounts": { + "title": "Écoles et comptes", + "description": "Les écoles et services que Schuly synchronise pour toi.", + "add": "Ajouter", + "addAria": "Ajouter une école ou un compte", + "loading": "Chargement des comptes liés", + "groupAria": "Comptes {{name}}", + "loadFailed": "Impossible de charger les comptes", + "noAnswer": "Ce service n’a pas répondu. Réessaie plus tard.", + "status": { + "ok": "Connecté", + "syncing": "Synchronisation", + "expired": "Connexion expirée", + "error": "Problème" + }, + "signInAgain": "Se reconnecter", + "signInAgainAria": "Se reconnecter à {{name}}", + "reauth": "Se réauthentifier", + "reauthAria": "Se réauthentifier à {{name}}", + "unlink": "Délier", + "unlinkAria": "Délier {{name}}", + "unlinkTitle": "Délier {{name}} ?", + "unlinkText": "Schuly arrête de le lire et supprime ce qu’il en a enregistré. Tu peux le rajouter à tout moment.", + "none": "Aucune école ni aucun compte lié pour l’instant. Ajoutes-en un pour lancer la synchronisation.", + "linked": "{{name}} lié", + "accountLinked": "Compte lié", + "finishFailed": "Impossible de terminer la connexion.", + "unlinked": "{{name}} délié" + }, + "security": { + "title": "Sécurité", + "description": "Comment tu te connectes à Schuly et où tu es connecté.", + "password": { + "label": "Mot de passe", + "set": "Défini", + "notSet": "Non défini · tu te connectes avec un fournisseur", + "change": "Modifier", + "add": "Ajouter un mot de passe" + }, + "twoFactor": { + "label": "Authentification à deux facteurs", + "on": "Activée · {count, plural, one {# code de récupération restant} other {# codes de récupération restants}}", + "off": "Désactivée · un code de ton téléphone à chaque nouvelle connexion", + "setUp": "Configurer", + "turnOff": "Désactiver" + }, + "providers": { + "title": "Fournisseurs de connexion", + "link": "Lier", + "linkAria": "Lier un fournisseur de connexion", + "unlink": "Délier", + "unlinkAria": "Délier {{name}}", + "none": "Aucun fournisseur lié. Lies-en un pour te connecter sans mot de passe." + }, + "devices": { + "title": "Appareils", + "lastActive": "Dernière activité : {{when}}", + "thisDevice": "Cet appareil", + "signOut": "Déconnecter", + "signOutAria": "Déconnecter {{name}}", + "signedOut": "{{name}} déconnecté", + "remembered": "{count, plural, one {# appareil saute} other {# appareils sautent}} le code à deux facteurs.", + "forget": "Oublier les appareils mémorisés", + "unknown": "Appareil inconnu", + "app": "App Schuly", + "on": "{{browser}} sur {{system}}" + }, + "passwordDialog": { + "changeTitle": "Modifier le mot de passe", + "addTitle": "Ajouter un mot de passe", + "changeDescription": "Ensuite, reconnecte-toi avec le nouveau, ici et sur tes autres appareils.", + "addDescription": "Tu pourras alors aussi te connecter avec ton e-mail et ce mot de passe.", + "newPassword": "Nouveau mot de passe", + "newPasswordHint": "Au moins {{min}} caractères. Une phrase dont tu te souviens fonctionne bien.", + "submitChange": "Modifier le mot de passe", + "submitAdd": "Ajouter le mot de passe" + }, + "twoFactorDialog": { + "title": "Configurer l’authentification à deux facteurs", + "step": "Étape {{step}} sur 3 · {{label}}", + "steps": { + "proof": "confirme que c’est toi", + "app": "connecte ton app", + "codes": "codes de récupération" + }, + "codesTitle": "Enregistre tes codes de récupération", + "codesText": "Chacun te connecte une fois si tu perds ton téléphone. Garde-les en lieu sûr, pas seulement sur cet appareil. Ils ne sont affichés que maintenant.", + "codesAria": "Codes de récupération", + "copyCodes": "Copier les codes", + "copied": "Codes de récupération copiés", + "copyFailed": "Copie impossible. Sélectionne les codes et copie-les toi-même.", + "saved": "Je les ai enregistrés", + "scan": "Scanne ceci avec ton app d’authentification, puis saisis le code à 6 chiffres qu’elle affiche.", + "qrAlt": "Code QR pour ton app d’authentification", + "cantScan": "Impossible de scanner ? Saisis", + "codeLabel": "Code de l’app", + "verify": "Vérifier", + "appTitle": "Il te faut une app d’authentification", + "appText": "Microsoft Authenticator, Google Authenticator ou ton gestionnaire de mots de passe." + }, + "twoFactorOff": { + "title": "Désactiver l’authentification à deux facteurs", + "description": "Pour te connecter, il ne faudra alors plus que ton mot de passe ou ton fournisseur.", + "codeLabel": "Code de ton app ou code de récupération" + }, + "linkPick": { + "title": "Lier un fournisseur", + "description": "Choisis-en un pour te connecter, en plus de ceux que tu as.", + "linkAria": "Lier {{name}}" + }, + "link": { + "title": "Lier {{name}}", + "description": "Tu pourras alors aussi te connecter avec {{name}}.", + "codeLabel": "Code de ton app", + "submit": "Continuer vers {{name}}" + }, + "unlink": { + "title": "Délier {{name}} ?", + "description": "Tu ne pourras plus te connecter avec {{name}} et tu seras déconnecté partout, ici aussi." + }, + "lastWayIn": { + "title": "Tu ne peux pas encore délier {{name}}", + "alertTitle": "C’est ton seul moyen de connexion", + "alertText": "Ajoute d’abord un mot de passe ou lie un autre fournisseur, sinon tu te bloquerais.", + "addPassword": "Ajouter un mot de passe" + }, + "signOutDialog": { + "title": "Déconnecter {{name}} ?", + "description": "Il devra se reconnecter pour utiliser Schuly." + }, + "signInAgain": { + "description": "Pour ta sécurité, modifier ta connexion met fin à toutes les sessions, y compris celle-ci.", + "button": "Se reconnecter" + }, + "messages": { + "loadFailed": "Impossible de charger tes paramètres de sécurité.", + "passwordAdded": "Ton mot de passe est ajouté. Reconnecte-toi.", + "passwordChanged": "Ton mot de passe est modifié. Reconnecte-toi avec le nouveau.", + "checkCurrentPassword": "Cela n’a pas fonctionné. Vérifie ton mot de passe actuel.", + "emailSent": "Nous avons envoyé une confirmation à {{email}}. Ton e-mail change dès que tu la confirmes.", + "checkPassword": "Cela n’a pas fonctionné. Vérifie ton mot de passe.", + "codeMismatch": "Ce code ne correspond pas. Essaie celui affiché actuellement dans ton app.", + "twoFactorOn": "L’authentification à deux facteurs est activée. Reconnecte-toi avec un code de ton app.", + "twoFactorOff": "L’authentification à deux facteurs est désactivée. Reconnecte-toi.", + "codeOrPassword": "Le code ou le mot de passe ne correspond pas.", + "unlinked": "Le fournisseur est délié. Reconnecte-toi.", + "unlinkFailed": "Cela n’a pas fonctionné. Vérifie ton code ou ton mot de passe et garde au moins un moyen de connexion.", + "linked": "Le fournisseur est lié. Tu peux maintenant te connecter avec.", + "signOutFailed": "Impossible de déconnecter cet appareil.", + "forgetFailed": "Impossible d’oublier tes appareils." + } + }, + "proof": { + "currentPassword": "Mot de passe actuel", + "continueWith": "Continuer avec {{name}}", + "signOutAndIn": "Déconnecte-toi puis reconnecte-toi, puis réessaie.", + "reason": { + "addPassword": "Pour ajouter un mot de passe, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "twoFactor": "Pour activer l’authentification à deux facteurs, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "twoFactorOff": "Pour désactiver l’authentification à deux facteurs, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "link": "Pour lier {{name}}, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "unlink": "Pour délier {{name}}, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "email": "Pour changer ton e-mail, confirme que c’est toi en te reconnectant. Tu reviens directement ici.", + "delete": "Pour supprimer ton compte, confirme que c’est toi en te reconnectant. Tu reviens directement ici." + } + }, + "danger": { + "title": "Zone de danger", + "description": "Modifications du compte lui-même.", + "email": { + "label": "Changer l’e-mail", + "text": "Où Schuly envoie les codes et les avis.", + "change": "Modifier", + "description": "Nous envoyons un lien à la nouvelle adresse ; ton e-mail change dès que tu l’ouvres.", + "passwordFirst": "Ajoute d’abord un mot de passe", + "passwordFirstText": "Ton e-mail ici appartient au fournisseur avec lequel tu te connectes. Ajoute un mot de passe sous Sécurité, puis tu pourras choisir le tien.", + "checkInbox": "Consulte ta boîte de réception", + "sentTo": "Nous avons envoyé un lien à {{email}}. Ouvre-le pour terminer le changement.", + "newEmail": "Nouvel e-mail", + "submit": "Envoyer le lien" + }, + "export": { + "label": "Exporter tes données", + "text": "Un fichier avec ton profil, tes comptes liés et les données scolaires synchronisées.", + "button": "Exporter" + }, + "delete": { + "text": "Supprime ton compte et tout ce que Schuly a enregistré. C’est irréversible.", + "description": "Ton profil, tes comptes liés et toutes les données scolaires synchronisées sont supprimés. Tes comptes scolaires eux-mêmes restent intacts.", + "alertTitle": "C’est irréversible", + "alertText": "Exporte d’abord tes données si tu veux les garder.", + "typeConfirm": "Saisis {{word}} pour confirmer", + "word": "SUPPRIMER" + } } }, "chat": { diff --git a/src/Schuly.Frontend/public/i18n/it.json b/src/Schuly.Frontend/public/i18n/it.json index dcce083a..4a887ef3 100644 --- a/src/Schuly.Frontend/public/i18n/it.json +++ b/src/Schuly.Frontend/public/i18n/it.json @@ -291,7 +291,14 @@ "invalid": "Questa conferma non è più valida. Torna all'accesso e ricomincia.", "sendFailed": "Qualcosa è andato storto nell'invio del codice. Riprova tra poco.", "mismatch": "Il codice non corrisponde. Controlla l'e-mail e riprova.", - "expired": "Questo codice è scaduto. Possiamo inviartene uno nuovo." + "expired": "Questo codice è scaduto. Possiamo inviartene uno nuovo.", + "changeAddress": "Indirizzo sbagliato? Cambia e-mail", + "newAddress": "Il tuo indirizzo e-mail", + "newAddressHint": "Ti mandiamo lì un nuovo codice. Quello vecchio non funziona più.", + "sendCode": "Invia il codice", + "sentToNew": "Abbiamo mandato un nuovo codice al tuo nuovo indirizzo.", + "addressTaken": "Un altro account usa già questo indirizzo e-mail.", + "addressInvalid": "Inserisci un indirizzo e-mail valido." }, "verify": "Verifica", "backToSignIn": "Torna all'accesso", @@ -338,7 +345,8 @@ "account_exists": "Esiste già un account con questa e-mail. Accedi con la password, poi collega questo provider nelle impostazioni.", "linked_to_another_account": "Questo accesso appartiene già a un altro account Schuly.", "provider_already_linked": "Questo provider è già collegato al tuo account.", - "external_login_failed": "L'accesso con questo provider non ha funzionato. Riprova." + "external_login_failed": "L'accesso con questo provider non ha funzionato. Riprova.", + "email_not_verified": "Questo provider non ha confermato il tuo indirizzo e-mail, quindi non è stato creato nessun account. Confermalo prima lì o crea un account con la tua e-mail." }, "tooMany": "Troppi tentativi. Aspetta un minuto e riprova.", "unreachable": "Schuly non è raggiungibile al momento.", @@ -396,6 +404,208 @@ "confirmTitle": "Eliminare il tuo account?", "confirmDescription": "Elimina definitivamente il tuo account Schuly, gli account scolastici collegati e tutte le note, verifiche, assenze e documenti sincronizzati sul server.", "password": "La tua password (lascia vuoto se usi solo il single sign-on)" + }, + "profile": { + "label": "Profilo", + "changePicture": "Cambia l’immagine del profilo", + "upload": "Carica un’immagine…", + "confirmed": "Confermata", + "tooLarge": "L’immagine può pesare al massimo 2 MB." + }, + "accounts": { + "title": "Scuole e account", + "description": "Le scuole e i servizi che Schuly sincronizza per te.", + "add": "Aggiungi", + "addAria": "Aggiungi una scuola o un account", + "loading": "Caricamento degli account collegati", + "groupAria": "Account {{name}}", + "loadFailed": "Impossibile caricare gli account", + "noAnswer": "Questo servizio non ha risposto. Riprova più tardi.", + "status": { + "ok": "Connesso", + "syncing": "Sincronizzazione", + "expired": "Accesso scaduto", + "error": "Problema" + }, + "signInAgain": "Accedi di nuovo", + "signInAgainAria": "Accedi di nuovo a {{name}}", + "reauth": "Autenticati di nuovo", + "reauthAria": "Autenticati di nuovo su {{name}}", + "unlink": "Scollega", + "unlinkAria": "Scollega {{name}}", + "unlinkTitle": "Scollegare {{name}}?", + "unlinkText": "Schuly smette di leggerlo e cancella quanto ne ha salvato. Puoi aggiungerlo di nuovo in qualsiasi momento.", + "none": "Nessuna scuola o account collegato. Aggiungine uno per iniziare a sincronizzare.", + "linked": "{{name}} collegato", + "accountLinked": "Account collegato", + "finishFailed": "Impossibile completare l’accesso.", + "unlinked": "{{name}} scollegato" + }, + "security": { + "title": "Sicurezza", + "description": "Come accedi a Schuly e dove hai effettuato l’accesso.", + "password": { + "label": "Password", + "set": "Impostata", + "notSet": "Non impostata · accedi con un provider", + "change": "Cambia", + "add": "Aggiungi password" + }, + "twoFactor": { + "label": "Autenticazione a due fattori", + "on": "Attiva · {count, plural, one {# codice di recupero rimasto} other {# codici di recupero rimasti}}", + "off": "Disattivata · un codice dal tuo telefono a ogni nuovo accesso", + "setUp": "Configura", + "turnOff": "Disattiva" + }, + "providers": { + "title": "Provider di accesso", + "link": "Collega", + "linkAria": "Collega un provider di accesso", + "unlink": "Scollega", + "unlinkAria": "Scollega {{name}}", + "none": "Nessun provider collegato. Collegane uno per accedere senza password." + }, + "devices": { + "title": "Dispositivi", + "lastActive": "Ultima attività: {{when}}", + "thisDevice": "Questo dispositivo", + "signOut": "Disconnetti", + "signOutAria": "Disconnetti {{name}}", + "signedOut": "{{name}} disconnesso", + "remembered": "{count, plural, one {# dispositivo salta} other {# dispositivi saltano}} il codice a due fattori.", + "forget": "Dimentica i dispositivi memorizzati", + "unknown": "Dispositivo sconosciuto", + "app": "App Schuly", + "on": "{{browser}} su {{system}}" + }, + "passwordDialog": { + "changeTitle": "Cambia password", + "addTitle": "Aggiungi una password", + "changeDescription": "Poi accedi di nuovo con quella nuova, qui e sugli altri tuoi dispositivi.", + "addDescription": "Poi potrai accedere anche con la tua e-mail e questa password.", + "newPassword": "Nuova password", + "newPasswordHint": "Almeno {{min}} caratteri. Una frase che ricordi funziona bene.", + "submitChange": "Cambia password", + "submitAdd": "Aggiungi password" + }, + "twoFactorDialog": { + "title": "Configura l’autenticazione a due fattori", + "step": "Passo {{step}} di 3 · {{label}}", + "steps": { + "proof": "conferma che sei tu", + "app": "collega la tua app", + "codes": "codici di recupero" + }, + "codesTitle": "Salva i tuoi codici di recupero", + "codesText": "Ognuno ti fa accedere una volta se perdi il telefono. Conservali in un posto sicuro, non solo su questo dispositivo. Vengono mostrati solo ora.", + "codesAria": "Codici di recupero", + "copyCodes": "Copia i codici", + "copied": "Codici di recupero copiati", + "copyFailed": "Impossibile copiare. Seleziona i codici e copiali tu.", + "saved": "Li ho salvati", + "scan": "Scansiona questo con la tua app di autenticazione, poi inserisci il codice a 6 cifre che mostra.", + "qrAlt": "Codice QR per la tua app di autenticazione", + "cantScan": "Non riesci a scansionare? Inserisci", + "codeLabel": "Codice dall’app", + "verify": "Verifica", + "appTitle": "Ti serve un’app di autenticazione", + "appText": "Microsoft Authenticator, Google Authenticator o il tuo gestore di password." + }, + "twoFactorOff": { + "title": "Disattiva l’autenticazione a due fattori", + "description": "Per accedere ti basterà poi la password o il provider.", + "codeLabel": "Codice dalla tua app o codice di recupero" + }, + "linkPick": { + "title": "Collega un provider", + "description": "Scegline uno con cui accedere, oltre a quelli che hai.", + "linkAria": "Collega {{name}}" + }, + "link": { + "title": "Collega {{name}}", + "description": "Poi potrai accedere anche con {{name}}.", + "codeLabel": "Codice dalla tua app", + "submit": "Continua su {{name}}" + }, + "unlink": { + "title": "Scollegare {{name}}?", + "description": "Non potrai più accedere con {{name}} e verrai disconnesso ovunque, anche qui." + }, + "lastWayIn": { + "title": "Non puoi ancora scollegare {{name}}", + "alertTitle": "È il tuo unico modo per accedere", + "alertText": "Aggiungi prima una password o collega un altro provider, altrimenti resteresti chiuso fuori.", + "addPassword": "Aggiungi una password" + }, + "signOutDialog": { + "title": "Disconnettere {{name}}?", + "description": "Dovrà accedere di nuovo per usare Schuly." + }, + "signInAgain": { + "description": "Per la tua sicurezza, cambiare il modo di accesso chiude tutte le sessioni, anche questa.", + "button": "Accedi di nuovo" + }, + "messages": { + "loadFailed": "Impossibile caricare le tue impostazioni di sicurezza.", + "passwordAdded": "La password è stata aggiunta. Accedi di nuovo.", + "passwordChanged": "La password è cambiata. Accedi di nuovo con quella nuova.", + "checkCurrentPassword": "Non ha funzionato. Controlla la password attuale.", + "emailSent": "Abbiamo mandato una conferma a {{email}}. La tua e-mail cambia appena la confermi.", + "checkPassword": "Non ha funzionato. Controlla la password.", + "codeMismatch": "Il codice non corrisponde. Prova quello attuale nella tua app.", + "twoFactorOn": "L’autenticazione a due fattori è attiva. Accedi di nuovo con un codice dalla tua app.", + "twoFactorOff": "L’autenticazione a due fattori è disattivata. Accedi di nuovo.", + "codeOrPassword": "Il codice o la password non corrisponde.", + "unlinked": "Il provider è scollegato. Accedi di nuovo.", + "unlinkFailed": "Non ha funzionato. Controlla codice o password e tieni almeno un modo per accedere.", + "linked": "Il provider è collegato. Ora puoi accedere con esso.", + "signOutFailed": "Impossibile disconnettere questo dispositivo.", + "forgetFailed": "Impossibile dimenticare i tuoi dispositivi." + } + }, + "proof": { + "currentPassword": "Password attuale", + "continueWith": "Continua con {{name}}", + "signOutAndIn": "Esci e accedi di nuovo, poi riprova.", + "reason": { + "addPassword": "Per aggiungere una password, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "twoFactor": "Per attivare l’autenticazione a due fattori, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "twoFactorOff": "Per disattivare l’autenticazione a due fattori, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "link": "Per collegare {{name}}, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "unlink": "Per scollegare {{name}}, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "email": "Per cambiare la tua e-mail, conferma che sei tu accedendo di nuovo. Torni subito qui.", + "delete": "Per eliminare il tuo account, conferma che sei tu accedendo di nuovo. Torni subito qui." + } + }, + "danger": { + "title": "Zona pericolosa", + "description": "Modifiche all’account stesso.", + "email": { + "label": "Cambia e-mail", + "text": "Dove Schuly invia codici e avvisi.", + "change": "Cambia", + "description": "Mandiamo un link al nuovo indirizzo; la tua e-mail cambia appena lo apri.", + "passwordFirst": "Aggiungi prima una password", + "passwordFirstText": "La tua e-mail qui appartiene al provider con cui accedi. Aggiungi una password in Sicurezza, poi potrai sceglierne una tua.", + "checkInbox": "Controlla la tua casella", + "sentTo": "Abbiamo mandato un link a {{email}}. Aprilo per completare la modifica.", + "newEmail": "Nuova e-mail", + "submit": "Invia il link" + }, + "export": { + "label": "Esporta i tuoi dati", + "text": "Un file con il tuo profilo, gli account collegati e i dati scolastici sincronizzati.", + "button": "Esporta" + }, + "delete": { + "text": "Rimuove il tuo account e tutto ciò che Schuly ha salvato. Non si può annullare.", + "description": "Il tuo profilo, gli account collegati e tutti i dati scolastici sincronizzati vengono eliminati. I tuoi account scolastici restano intatti.", + "alertTitle": "Non si può annullare", + "alertText": "Esporta prima i tuoi dati se vuoi tenerli.", + "typeConfirm": "Scrivi {{word}} per confermare", + "word": "ELIMINA" + } } }, "chat": { diff --git a/src/Schuly.Frontend/public/i18n/rm.json b/src/Schuly.Frontend/public/i18n/rm.json index abac2e51..73c52c13 100644 --- a/src/Schuly.Frontend/public/i18n/rm.json +++ b/src/Schuly.Frontend/public/i18n/rm.json @@ -291,7 +291,14 @@ "invalid": "Questa confermaziun n'è betg pli valida. Turna a l'annunzia e cumenza da nov.", "sendFailed": "Insatge è ì mal cun trametter il code. Emprova anc ina giada en in mument.", "mismatch": "Il code na correspunda betg. Controllescha l'e-mail ed emprova anc ina giada.", - "expired": "Quest code è scadì. Nus pudain trametter a tai in nov." + "expired": "Quest code è scadì. Nus pudain trametter a tai in nov.", + "changeAddress": "Adressa sbagliada? Midar l’e-mail", + "newAddress": "Tia adressa d’e-mail", + "newAddressHint": "Nus ta tramettain là in nov code. Il vegl na funcziuna betg pli.", + "sendCode": "Trametter il code", + "sentToNew": "Nus avain tramess in nov code a tia nova adressa.", + "addressTaken": "In auter conto utilisescha gia questa adressa d’e-mail.", + "addressInvalid": "Endatescha ina adressa d’e-mail valida." }, "verify": "Verifitgar", "backToSignIn": "Enavos a l'annunzia", @@ -338,7 +345,8 @@ "account_exists": "I dat gia in conto cun questa adressa dad e-mail. T'annunzia cun tes pled-clav e collia lura quest purschider en ils parameters.", "linked_to_another_account": "Questa annunzia tutga gia tar in auter conto Schuly.", "provider_already_linked": "Quest purschider è gia collià cun tes conto.", - "external_login_failed": "L'annunzia cun quest purschider n'ha betg funcziunà. Emprova anc ina giada." + "external_login_failed": "L'annunzia cun quest purschider n'ha betg funcziunà. Emprova anc ina giada.", + "email_not_verified": "Quest purschider n’ha betg confermà tia adressa d’e-mail, perquai n’è nagin conto vegnì creà. Conferma l’adressa l’emprim là u creescha in conto cun tia e-mail." }, "tooMany": "Memia bleras emprovas. Spetga ina minuta ed emprova anc ina giada.", "unreachable": "Schuly n'è per il mument betg cuntanschibel.", @@ -396,6 +404,208 @@ "confirmTitle": "Stizzar tes conto?", "confirmDescription": "Quai stizza definitivamain tes conto Schuly, tes contos da scola colliads e tut las notas, tests, absenzas e documents sincronisads sin il server.", "password": "Tes pled-clav (lascha vid, sche ti duvras mo single sign-on)" + }, + "profile": { + "label": "Profil", + "changePicture": "Midar il maletg dal profil", + "upload": "Chargiar si in maletg…", + "confirmed": "Confermà", + "tooLarge": "Il maletg dastga esser maximalmain 2 MB grond." + }, + "accounts": { + "title": "Scolas & contos", + "description": "Las scolas e ils servetschs che Schuly sincronisescha per tai.", + "add": "Agiuntar", + "addAria": "Agiuntar ina scola u in conto", + "loading": "Ils contos colliads vegnan chargiads", + "groupAria": "Contos tar {{name}}", + "loadFailed": "Ils contos n’han betg pudì vegnir chargiads", + "noAnswer": "Quest servetsch n’ha betg respundì. Emprova pli tard anc ina giada.", + "status": { + "ok": "Collià", + "syncing": "Vegn sincronisà", + "expired": "Annunzia scadida", + "error": "Problem" + }, + "signInAgain": "S’annunziar da nov", + "signInAgainAria": "S’annunziar da nov tar {{name}}", + "reauth": "S’autentifitgar da nov", + "reauthAria": "S’autentifitgar da nov tar {{name}}", + "unlink": "Separar", + "unlinkAria": "Separar {{name}}", + "unlinkTitle": "Separar {{name}}?", + "unlinkText": "Schuly na legia betg pli quai e stizza tut quai ch’el ha memorisà da quai. Ti pos agiuntar quai da nov da tut temp.", + "none": "Anc naginas scolas u contos colliads. Agiunta in, per ch’il sincronisar cumenza.", + "linked": "{{name}} collià", + "accountLinked": "Conto collià", + "finishFailed": "L’annunzia n’ha betg pudì vegnir terminada.", + "unlinked": "{{name}} separà" + }, + "security": { + "title": "Segirezza", + "description": "Co che ti t’annunzias tar Schuly e nua che ti es annunzià.", + "password": { + "label": "Pled-clav", + "set": "Definì", + "notSet": "Betg definì · ti t’annunzias cun in purschider", + "change": "Midar", + "add": "Agiuntar in pled-clav" + }, + "twoFactor": { + "label": "Autentificaziun da dus facturs", + "on": "Activ · {count, plural, one {# code da recuperaziun restant} other {# codes da recuperaziun restants}}", + "off": "Deactivà · in code da tes telefonin tar mintga nova annunzia", + "setUp": "Installar", + "turnOff": "Deactivar" + }, + "providers": { + "title": "Purschiders d’annunzia", + "link": "Colliar", + "linkAria": "Colliar in purschider d’annunzia", + "unlink": "Separar", + "unlinkAria": "Separar {{name}}", + "none": "Nagin purschider collià. Collia in per t’annunziar senza pled-clav." + }, + "devices": { + "title": "Apparats", + "lastActive": "L’ultima giada activ: {{when}}", + "thisDevice": "Quest apparat", + "signOut": "Deconnectar", + "signOutAria": "Deconnectar {{name}}", + "signedOut": "{{name}} deconnectà", + "remembered": "{count, plural, one {# apparat sursiglia} other {# apparats sursiglian}} il code da dus facturs.", + "forget": "Emblidar ils apparats memorisads", + "unknown": "Apparat nunenconuschent", + "app": "App Schuly", + "on": "{{browser}} sin {{system}}" + }, + "passwordDialog": { + "changeTitle": "Midar il pled-clav", + "addTitle": "Agiuntar in pled-clav", + "changeDescription": "Silsuenter t’annunzias ti cun il nov, qua e sin tes auters apparats.", + "addDescription": "Silsuenter pos ti t’annunziar er cun tia e-mail e quest pled-clav.", + "newPassword": "Nov pled-clav", + "newPasswordHint": "Almain {{min}} caracters. Ina frasa che ti ta regordas va bain.", + "submitChange": "Midar il pled-clav", + "submitAdd": "Agiuntar il pled-clav" + }, + "twoFactorDialog": { + "title": "Installar l’autentificaziun da dus facturs", + "step": "Pass {{step}} da 3 · {{label}}", + "steps": { + "proof": "conferma che ti es ti", + "app": "colliar tia app", + "codes": "codes da recuperaziun" + }, + "codesTitle": "Memorisescha tes codes da recuperaziun", + "codesText": "Mintgin t’annunzia ina giada, sche ti perdas tes telefonin. Tegna els en in lieu segir, betg mo sin quest apparat. Els vegnan mussads mo ussa.", + "codesAria": "Codes da recuperaziun", + "copyCodes": "Copiar ils codes", + "copied": "Codes da recuperaziun copiads", + "copyFailed": "Copiar n’ha betg funcziunà. Marca ils codes e copiescha els sez.", + "saved": "Jau als hai memorisà", + "scan": "Scannescha quai cun tia app d’autentificaziun ed endatescha lura il code da 6 cifras ch’ella mussa.", + "qrAlt": "Code QR per tia app d’autentificaziun", + "cantScan": "Scannar na va betg? Endatescha", + "codeLabel": "Code da l’app", + "verify": "Verifitgar", + "appTitle": "Ti dovras ina app d’autentificaziun", + "appText": "Microsoft Authenticator, Google Authenticator u tes administratur da pleds-clav." + }, + "twoFactorOff": { + "title": "Deactivar l’autentificaziun da dus facturs", + "description": "Per t’annunziar dovras ti lura mo pli tes pled-clav u tes purschider.", + "codeLabel": "Code da tia app u in code da recuperaziun" + }, + "linkPick": { + "title": "Colliar in purschider", + "description": "Tscherna in, cun il qual ti vuls t’annunziar supplementarmain.", + "linkAria": "Colliar {{name}}" + }, + "link": { + "title": "Colliar {{name}}", + "description": "Silsuenter pos ti t’annunziar er cun {{name}}.", + "codeLabel": "Code da tia app", + "submit": "Vinavant tar {{name}}" + }, + "unlink": { + "title": "Separar {{name}}?", + "description": "Ti na pos lura betg pli t’annunziar cun {{name}} e vegns deconnectà dapertut, er qua." + }, + "lastWayIn": { + "title": "Ti na pos anc betg separar {{name}}", + "alertTitle": "Quai è tia suletta pussaivladad d’annunzia", + "alertText": "Agiunta l’emprim in pled-clav u collia in auter purschider, uschiglio ta serras ti ora.", + "addPassword": "Agiuntar in pled-clav" + }, + "signOutDialog": { + "title": "Deconnectar {{name}}?", + "description": "L’apparat sto s’annunziar da nov per duvrar Schuly." + }, + "signInAgain": { + "description": "Per tia segirezza termina ina midada da tia annunzia tut las sesidas, er questa.", + "button": "S’annunziar da nov" + }, + "messages": { + "loadFailed": "Tes parameters da segirezza n’han betg pudì vegnir chargiads.", + "passwordAdded": "Tes pled-clav è agiuntà. T’annunzia da nov.", + "passwordChanged": "Tes pled-clav è midà. T’annunzia da nov cun il nov.", + "checkCurrentPassword": "Quai n’ha betg funcziunà. Controllescha tes pled-clav actual.", + "emailSent": "Nus avain tramess ina conferma a {{email}}. Tia e-mail sa mida uschespert che ti la confermas.", + "checkPassword": "Quai n’ha betg funcziunà. Controllescha tes pled-clav.", + "codeMismatch": "Quest code na correspunda betg. Emprova l’actual da tia app.", + "twoFactorOn": "L’autentificaziun da dus facturs è activa. T’annunzia da nov cun in code da tia app.", + "twoFactorOff": "L’autentificaziun da dus facturs è deactivada. T’annunzia da nov.", + "codeOrPassword": "Il code u il pled-clav na correspunda betg.", + "unlinked": "Il purschider è separà. T’annunzia da nov.", + "unlinkFailed": "Quai n’ha betg funcziunà. Controllescha code u pled-clav e tegna almain ina pussaivladad d’annunzia.", + "linked": "Il purschider è collià. Ti pos ussa t’annunziar cun el.", + "signOutFailed": "Quest apparat n’ha betg pudì vegnir deconnectà.", + "forgetFailed": "Tes apparats n’han betg pudì vegnir emblidads." + } + }, + "proof": { + "currentPassword": "Pled-clav actual", + "continueWith": "Vinavant cun {{name}}", + "signOutAndIn": "Deconnectescha e t’annunzia da nov, lura emprova anc ina giada.", + "reason": { + "addPassword": "Per agiuntar in pled-clav, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "twoFactor": "Per activar l’autentificaziun da dus facturs, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "twoFactorOff": "Per deactivar l’autentificaziun da dus facturs, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "link": "Per colliar {{name}}, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "unlink": "Per separar {{name}}, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "email": "Per midar tia e-mail, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua.", + "delete": "Per stizzar tes conto, conferma cun ina nova annunzia che ti es ti. Ti turnas directamain qua." + } + }, + "danger": { + "title": "Zona privlusa", + "description": "Midadas vi dal conto sez.", + "email": { + "label": "Midar l’e-mail", + "text": "Nua che Schuly tramet codes ed avis.", + "change": "Midar", + "description": "Nus tramettain ina colliaziun a la nova adressa; tia e-mail sa mida uschespert che ti l’averschas.", + "passwordFirst": "Agiunta l’emprim in pled-clav", + "passwordFirstText": "Tia e-mail qua tutga tar il purschider cun il qual ti t’annunzias. Agiunta sut Segirezza in pled-clav, lura pos ti tscherner tia atgna.", + "checkInbox": "Guarda en tia chascha da posta", + "sentTo": "Nus avain tramess ina colliaziun a {{email}}. Avra la per terminar la midada.", + "newEmail": "Nova e-mail", + "submit": "Trametter la colliaziun" + }, + "export": { + "label": "Exportar tias datas", + "text": "Ina datoteca cun tes profil, tes contos colliads e las datas da scola sincronisadas.", + "button": "Exportar" + }, + "delete": { + "text": "Allontanescha tes conto e tut quai che Schuly ha memorisà. Quai na po betg vegnir revocà.", + "description": "Tes profil, tes contos colliads e tut las datas da scola sincronisadas vegnan stizzads. Tes contos da scola sezs restan intacts.", + "alertTitle": "Quai na po betg vegnir revocà", + "alertText": "Exportescha l’emprim tias datas, sche ti las vuls tegnair.", + "typeConfirm": "Endatescha {{word}} per confermar", + "word": "STIZZAR" + } } }, "chat": { diff --git a/src/Schuly.Frontend/src/app/core/signed-in-at.ts b/src/Schuly.Frontend/src/app/core/signed-in-at.ts index 9d1394a3..45b1943a 100644 --- a/src/Schuly.Frontend/src/app/core/signed-in-at.ts +++ b/src/Schuly.Frontend/src/app/core/signed-in-at.ts @@ -34,11 +34,20 @@ export function proofMode(hasPassword: boolean, fresh = signedInRecently()): Pro return fresh ? 'none' : 'reauth'; } +/** The words around the names, so the caller can translate them. */ +export interface AgentWords { + unknown: string; + app: string; + on: (browser: string, system: string) => string; +} + +const ENGLISH: AgentWords = { unknown: 'Unknown device', app: 'Schuly app', on: (browser, system) => `${browser} on ${system}` }; + /** "Firefox on Linux" from a raw user agent; the Schuly app names itself. */ -export function describeAgent(agent: string | null | undefined): string { - if (!agent) return 'Unknown device'; - if (/^schuly|dart\//i.test(agent)) return /android/i.test(agent) ? 'Schuly app on Android' : /ios|iphone|ipad/i.test(agent) ? 'Schuly app on iOS' : 'Schuly app'; +export function describeAgent(agent: string | null | undefined, words: AgentWords = ENGLISH): string { + if (!agent) return words.unknown; + if (/^schuly|dart\//i.test(agent)) return /android/i.test(agent) ? words.on(words.app, 'Android') : /ios|iphone|ipad/i.test(agent) ? words.on(words.app, 'iOS') : words.app; const browser = /Edg\//.test(agent) ? 'Edge' : /OPR\//.test(agent) ? 'Opera' : /Firefox\//.test(agent) ? 'Firefox' : /Chrome\//.test(agent) ? 'Chrome' : /Safari\//.test(agent) ? 'Safari' : 'Browser'; const system = /Windows/.test(agent) ? 'Windows' : /Android/.test(agent) ? 'Android' : /iPhone|iPad/.test(agent) ? 'iOS' : /Mac OS/.test(agent) ? 'macOS' : /Linux/.test(agent) ? 'Linux' : ''; - return system ? `${browser} on ${system}` : browser; + return system ? words.on(browser, system) : browser; } diff --git a/src/Schuly.Frontend/src/app/pages/auth/auth-layout.ts b/src/Schuly.Frontend/src/app/pages/auth/auth-layout.ts index e5a644d7..a9bad52d 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/auth-layout.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/auth-layout.ts @@ -71,7 +71,7 @@ import { ThemeStore } from '../../stores/theme.store';
- +

© {{ year }} Schuly

diff --git a/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts b/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts index 38de11e9..e714ec74 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/confirm-email.ts @@ -93,22 +93,22 @@ export function confirmEmailHint(challenge: EmailChallenge | null): string { @if (changing()) {
- + -

We send a new code there. The old one stops working.

+

{{ 'auth.confirm.newAddressHint' | translate }}

- +
} @else { - + } @@ -195,18 +195,18 @@ export class ConfirmEmail { await this.auth.changeEmailCodeAddress(this.newAddress().trim()); this.model.set({ code: '' }); this.changing.set(false); - this.notice.set('We sent a new code to your new address.'); + this.notice.set(t('auth.confirm.sentToNew')); document.getElementById('email-code')?.focus(); } catch (error) { const status = error instanceof HttpErrorResponse ? error.status : 0; this.error.set( status === 409 - ? 'Another account already uses that email address.' + ? t('auth.confirm.addressTaken') : status === 400 - ? 'Enter a valid email address.' + ? t('auth.confirm.addressInvalid') : status === 401 - ? 'This confirmation is no longer valid. Go back to sign in and start again.' - : 'Something went wrong. Try again in a moment.', + ? t('auth.confirm.invalid') + : t('auth.confirm.sendFailed'), ); } finally { this.busy.set(false); diff --git a/src/Schuly.Frontend/src/app/pages/auth/login.ts b/src/Schuly.Frontend/src/app/pages/auth/login.ts index 4bb0950a..95d5f81e 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/login.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/login.ts @@ -1,11 +1,11 @@ +import { PasswordField } from '../../shared/password-field'; import { TranslatePipe } from '@ngx-translate/core'; import { t } from '../../core/i18n'; import { HttpErrorResponse } from '@angular/common/http'; import { Component, OnInit, inject, input, signal } from '@angular/core'; import { form, FormField, FormRoot, required } from '@angular/forms/signals'; import { Router, RouterLink } from '@angular/router'; -import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucideEye, lucideEyeOff } from '@ng-icons/lucide'; + import { BrnInputOtp } from '@spartan-ng/brain/input-otp'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmButton } from '@spartan-ng/helm/button'; @@ -13,7 +13,6 @@ import { HlmCardImports } from '@spartan-ng/helm/card'; import { HlmCheckbox } from '@spartan-ng/helm/checkbox'; import { HlmFieldImports } from '@spartan-ng/helm/field'; import { HlmInput } from '@spartan-ng/helm/input'; -import { HlmInputGroupImports } from '@spartan-ng/helm/input-group'; import { HlmInputOtpImports } from '@spartan-ng/helm/input-otp'; import { HlmSeparator } from '@spartan-ng/helm/separator'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; @@ -35,14 +34,13 @@ const CODE_LENGTH = 6; FormRoot, FormField, RouterLink, - NgIcon, BrnInputOtp, HlmButton, HlmCardImports, HlmCheckbox, HlmFieldImports, HlmInput, - HlmInputGroupImports, + PasswordField, HlmInputOtpImports, HlmSeparator, HlmSpinner, @@ -51,7 +49,7 @@ const CODE_LENGTH = 6; MicrosoftSignInButton, TranslatePipe, ], - providers: [provideIcons({ lucideEye, lucideEyeOff })], + template: ` {{ 'auth.login.forgot' | translate }} -
- -
- -
-
+ @if (error()) { {{ error() }} } @@ -240,7 +219,6 @@ export class Login implements OnInit { protected readonly codeLength = CODE_LENGTH; protected readonly slots = Array.from({ length: CODE_LENGTH }, (_, index) => index); protected readonly step = signal<'credentials' | 'email' | 'code'>('credentials'); - protected readonly passwordVisible = signal(false); protected readonly usingRecoveryCode = signal(false); protected readonly rememberDevice = signal(false); protected readonly error = signal(null); diff --git a/src/Schuly.Frontend/src/app/pages/auth/register.ts b/src/Schuly.Frontend/src/app/pages/auth/register.ts index 1de3b513..6cda498c 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/register.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/register.ts @@ -1,3 +1,4 @@ +import { PasswordField } from '../../shared/password-field'; import { TranslatePipe } from '@ngx-translate/core'; import { t } from '../../core/i18n'; import { Component, inject, signal } from '@angular/core'; @@ -17,6 +18,7 @@ import { ConfirmEmail, confirmEmailHint } from './confirm-email'; @Component({ selector: 'app-register', imports: [ + PasswordField, FormRoot, FormField, RouterLink, @@ -65,13 +67,7 @@ import { ConfirmEmail, confirmEmailHint } from './confirm-email';
- +

{{ 'auth.minLength' | translate }}

@if (error()) { diff --git a/src/Schuly.Frontend/src/app/pages/auth/reset-password.ts b/src/Schuly.Frontend/src/app/pages/auth/reset-password.ts index 08d55be1..a426e3d5 100644 --- a/src/Schuly.Frontend/src/app/pages/auth/reset-password.ts +++ b/src/Schuly.Frontend/src/app/pages/auth/reset-password.ts @@ -1,3 +1,4 @@ +import { PasswordField } from '../../shared/password-field'; import { TranslatePipe } from '@ngx-translate/core'; import { t } from '../../core/i18n'; import { Component, inject, input, signal } from '@angular/core'; @@ -7,7 +8,6 @@ import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; import { HlmFieldImports } from '@spartan-ng/helm/field'; -import { HlmInput } from '@spartan-ng/helm/input'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; import { firstValueFrom } from 'rxjs'; import { AuthApi } from '../../core/auth-api'; @@ -17,6 +17,7 @@ import { AuthLayout } from './auth-layout'; @Component({ selector: 'app-reset-password', imports: [ + PasswordField, FormRoot, FormField, RouterLink, @@ -24,7 +25,6 @@ import { AuthLayout } from './auth-layout'; HlmButton, HlmCardImports, HlmFieldImports, - HlmInput, HlmSpinner, AuthLayout, TranslatePipe, @@ -50,13 +50,7 @@ import { AuthLayout } from './auth-layout';
- +

{{ 'auth.minLength' | translate }}

@if (error()) { diff --git a/src/Schuly.Frontend/src/app/pages/settings/account-card.ts b/src/Schuly.Frontend/src/app/pages/settings/account-card.ts index 00f7a803..8fe6f80b 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/account-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/account-card.ts @@ -6,21 +6,23 @@ import { HlmAvatarImports } from '@spartan-ng/helm/avatar'; import { HlmCardImports } from '@spartan-ng/helm/card'; import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu'; import { UserStore } from '../../stores/user.store'; +import { TranslatePipe } from '@ngx-translate/core'; +import { t } from '../../core/i18n'; /** One profile line: the picture, changed from a small menu on it, then the name and sign-in email. */ @Component({ selector: 'app-account-card', - imports: [NgIcon, HlmAlertImports, HlmAvatarImports, HlmCardImports, HlmDropdownMenuImports], + imports: [NgIcon, HlmAlertImports, HlmAvatarImports, HlmCardImports, HlmDropdownMenuImports, TranslatePipe], providers: [provideIcons({ lucideLoader2, lucidePencil })], template: ` -
+
@@ -62,9 +64,9 @@ import { UserStore } from '../../stores/user.store'; - + @if (user.avatar()) { - + } @@ -79,7 +81,7 @@ export class AccountCard { input.value = ''; if (!file) return; if (file.size > 2_000_000) { - this.picError.set('The picture must be at most 2 MB.'); + this.picError.set(t('settings.profile.tooLarge')); return; } this.picError.set(null); diff --git a/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts b/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts index 7a4d7ace..6006ba71 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/accounts-card.ts @@ -15,29 +15,31 @@ import { failureMessage, isTerminal, linkErrorMessage } from '../../core/linking import { ProviderIcon } from '../../shared/provider-icon'; import { LinkedAccountsStore } from '../../stores/linked-accounts.store'; import { LinkDialog } from './link-dialog'; +import { TranslatePipe } from '@ngx-translate/core'; +import { t } from '../../core/i18n'; const STATUS: Record = { - ok: { label: 'Connected', dot: 'bg-green-500', tone: '' }, - syncing: { label: 'Syncing', dot: 'bg-muted-foreground', tone: '' }, - expired: { label: 'Sign-in expired', dot: 'bg-amber-500', tone: 'text-amber-700 dark:text-amber-300' }, - error: { label: 'Problem', dot: 'bg-destructive', tone: 'text-destructive' }, + ok: { label: 'settings.accounts.status.ok', dot: 'bg-green-500', tone: '' }, + syncing: { label: 'settings.accounts.status.syncing', dot: 'bg-muted-foreground', tone: '' }, + expired: { label: 'settings.accounts.status.expired', dot: 'bg-amber-500', tone: 'text-amber-700 dark:text-amber-300' }, + error: { label: 'settings.accounts.status.error', dot: 'bg-destructive', tone: 'text-destructive' }, }; /** Every linked school and service, grouped by provider, with add, re-authenticate and unlink. Nothing here knows a provider by name. */ @Component({ selector: 'app-accounts-card', - imports: [HlmAlertImports, HlmAlertDialogImports, HlmButton, HlmCardImports, HlmSpinner, NgIcon, LinkDialog, ProviderIcon], + imports: [TranslatePipe, HlmAlertImports, HlmAlertDialogImports, HlmButton, HlmCardImports, HlmSpinner, NgIcon, LinkDialog, ProviderIcon], providers: [provideIcons({ lucidePlus })], template: `
-

Schools & accounts

-

The schools and services Schuly syncs for you.

+

{{ 'settings.accounts.title' | translate }}

+

{{ 'settings.accounts.description' | translate }}

-
@@ -53,7 +55,7 @@ const STATUS: Record + } @else if (store.groups().length) { @for (group of store.groups(); track group.providerId) {
@@ -66,12 +68,12 @@ const STATUS: Record
-
    +
      @for (account of group.accounts; track account.id) { @if (!account.id) {
    • - Could not load the accounts - {{ account.message || 'This service did not answer. Try again later.' }} + {{ 'settings.accounts.loadFailed' | translate }} + {{ account.message || ('settings.accounts.noAnswer' | translate) }}
    • } @else {
    • @@ -84,7 +86,7 @@ const STATUS: Record - {{ status(account).label }} + {{ status(account).label | translate }} @if (account.message) { @@ -94,21 +96,21 @@ const STATUS: Record @if (account.canReauth) { @if (account.status === 'expired') { - + } @else { - + } } - + -

      Unlink {{ account.title }}?

      -

      Schuly stops reading it and deletes what it stored from it. You can add it again any time.

      +

      {{ 'settings.accounts.unlinkTitle' | translate: { name: account.title } }}

      +

      {{ 'settings.accounts.unlinkText' | translate }}

      - - + +
      @@ -120,7 +122,7 @@ const STATUS: Record } } @else if (!store.loadError()) { -

      No schools or accounts linked yet. Add one to start syncing.

      +

      {{ 'settings.accounts.none' | translate }}

      }
@@ -148,10 +150,10 @@ export class AccountsCard { const session = await firstValueFrom(this.api.poll(id)); const end = isTerminal(session); if (end === 'failed') toast.error(failureMessage(session)); - else if (end === 'done') toast.success(session.step.done?.title ? `Linked ${session.step.done.title}` : 'Account linked'); + else if (end === 'done') toast.success(session.step.done?.title ? t('settings.accounts.linked', { name: session.step.done.title }) : t('settings.accounts.accountLinked')); await this.store.afterLink(); } catch (error) { - toast.error(linkErrorMessage(error, 'Could not finish the sign-in.')); + toast.error(linkErrorMessage(error, t('settings.accounts.finishFailed'))); await this.store.afterLink(); } } @@ -165,6 +167,6 @@ export class AccountsCard { } protected async unlink(account: LinkedAccountDto): Promise { - if (await this.store.unlink(account)) toast.success(`Unlinked ${account.title}`); + if (await this.store.unlink(account)) toast.success(t('settings.accounts.unlinked', { name: account.title })); } } diff --git a/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts b/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts index 8e945001..ca46801a 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts @@ -1,6 +1,7 @@ import { Component, computed, effect, inject, input, signal, untracked, viewChild } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideDownload, lucideTriangleAlert } from '@ng-icons/lucide'; +import { TranslatePipe } from '@ngx-translate/core'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmCardImports } from '@spartan-ng/helm/card'; @@ -8,6 +9,8 @@ import { HlmDialogImports } from '@spartan-ng/helm/dialog'; import { HlmFieldImports } from '@spartan-ng/helm/field'; import { HlmInput } from '@spartan-ng/helm/input'; import { HlmSpinner } from '@spartan-ng/helm/spinner'; +import { t } from '../../core/i18n'; +import { UsernameHint } from '../../shared/password-field'; import { SecurityStore } from '../../stores/security.store'; import { UserStore } from '../../stores/user.store'; import { Proof } from './proof'; @@ -17,36 +20,36 @@ type Dialog = 'email' | 'export' | 'delete'; /** Changes to the account itself: its email, a copy of its data, and deleting it. */ @Component({ selector: 'app-danger-zone-card', - imports: [NgIcon, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, HlmSpinner, Proof], + imports: [NgIcon, TranslatePipe, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, HlmSpinner, UsernameHint, Proof], providers: [provideIcons({ lucideDownload, lucideTriangleAlert })], template: `
-

Danger zone

-

Changes to the account itself.

+

{{ 'settings.danger.title' | translate }}

+

{{ 'settings.danger.description' | translate }}

  • - Change email - Where Schuly sends codes and notices. + {{ 'settings.danger.email.label' | translate }} + {{ 'settings.danger.email.text' | translate }}
    - +
  • - Export your data - A file with your profile, linked accounts and synced school data. + {{ 'settings.danger.export.label' | translate }} + {{ 'settings.danger.export.text' | translate }}
    - +
  • - Delete account - Removes your account and everything Schuly stored. This can't be undone. + {{ 'settings.delete.title' | translate }} + {{ 'settings.danger.delete.text' | translate }}
    - +
@@ -57,83 +60,85 @@ type Dialog = 'email' | 'export' | 'delete'; @switch (dialog()) { @case ('email') { -

Change email

-

We send a link to the new address; your email changes once you open it.

+

{{ 'settings.danger.email.label' | translate }}

+

{{ 'settings.danger.email.description' | translate }}

@if (!user.hasPassword()) {
-

Add a password first

-

Your email here belongs to the provider you sign in with. Add a password in Security, then you can choose your own.

+

{{ 'settings.danger.email.passwordFirst' | translate }}

+

{{ 'settings.danger.email.passwordFirstText' | translate }}

- + } @else if (sentTo()) {
-

Check your inbox

-

We sent a link to {{ sentTo() }}. Open it to finish the change.

+

{{ 'settings.danger.email.checkInbox' | translate }}

+

{{ 'settings.danger.email.sentTo' | translate: { email: sentTo() } }}

- + } @else { +
- - + +
- + @if (error()) { } - - + + } } @case ('export') { -

Export your data

-

Download your account, school data, Helply chats, connected apps and devices as a JSON file. Document files stay in Documents, and the file lists anything else it leaves out.

+

{{ 'settings.danger.export.label' | translate }}

+

{{ 'settings.data.description' | translate }}

- + } @case ('delete') { -

Delete your account?

-

Your profile, linked accounts and all synced school data are deleted. Your school accounts themselves stay untouched.

+

{{ 'settings.delete.confirmTitle' | translate }}

+

{{ 'settings.danger.delete.description' | translate }}

+
- + @if (proof.mode() !== 'reauth') {
- - + +
} @if (user.deleteError(); as problem) { } - - + + } @@ -155,6 +160,8 @@ export class DangerZoneCard { protected readonly sentTo = signal(null); protected readonly busy = computed(() => this.security.busy()); protected readonly error = signal(null); + /** The word to type, in the page's language ("DELETE", "LÖSCHEN", ...). */ + protected readonly confirmWord = computed(() => t('settings.danger.delete.word').toUpperCase()); private readonly proof = viewChild('proof'); private resumed = false; diff --git a/src/Schuly.Frontend/src/app/pages/settings/proof.ts b/src/Schuly.Frontend/src/app/pages/settings/proof.ts index d24bad0b..cd1abc6e 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/proof.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/proof.ts @@ -1,45 +1,35 @@ -import { Component, computed, inject, input, model, signal } from '@angular/core'; +import { Component, computed, inject, input, model } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmFieldImports } from '@spartan-ng/helm/field'; -import { HlmInput } from '@spartan-ng/helm/input'; import { ProofMode, proofMode } from '../../core/signed-in-at'; +import { PasswordField } from '../../shared/password-field'; import { AuthStore } from '../../stores/auth.store'; import { SecurityStore } from '../../stores/security.store'; import { UserStore } from '../../stores/user.store'; -/** The proof part of a settings dialog. `resume` is the settings action to reopen after signing in again. */ +/** + * The proof part of a settings dialog. `reason` is the translated sentence that says why ("To delete your + * account, …"); `resume` is the settings action to reopen after signing in again. + */ @Component({ selector: 'app-proof', - imports: [HlmButton, HlmFieldImports, HlmInput], + imports: [HlmButton, HlmFieldImports, PasswordField, TranslatePipe], template: ` @switch (mode()) { @case ('password') {
- -
- - -
+ +
} @case ('reauth') {
-

To {{ what() }}, confirm it's you by signing in again. You come straight back here.

+

{{ reason() }}

@if (provider(); as p) { - + } @else { -

Sign out and sign in again, then try once more.

+

{{ 'settings.proof.signOutAndIn' | translate }}

}
} @@ -51,11 +41,11 @@ export class Proof { private readonly security = inject(SecurityStore); private readonly user = inject(UserStore); - readonly what = input.required(); + readonly reason = input.required(); readonly resume = input.required(); - readonly inputId = input('proof-password'); + /** Stable, so password managers recognise the field; only one dialog is open at a time. */ + readonly inputId = input('current-password'); readonly password = model(''); - protected readonly shown = signal(false); /** Read once per dialog: the dialog is rebuilt each time it opens. */ readonly mode = computed(() => proofMode(this.user.hasPassword())); diff --git a/src/Schuly.Frontend/src/app/pages/settings/security-card.ts b/src/Schuly.Frontend/src/app/pages/settings/security-card.ts index 4cbe268b..ef2e7645 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/security-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/security-card.ts @@ -2,6 +2,7 @@ import { Component, computed, effect, inject, input, signal, untracked, viewChil import { ActivatedRoute, Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideArrowLeft, lucideKeyRound, lucidePlus } from '@ng-icons/lucide'; +import { TranslatePipe } from '@ngx-translate/core'; import { toast } from '@spartan-ng/brain/sonner'; import { HlmAlertImports } from '@spartan-ng/helm/alert'; import { HlmButton } from '@spartan-ng/helm/button'; @@ -11,7 +12,9 @@ import { HlmFieldImports } from '@spartan-ng/helm/field'; import { HlmInput } from '@spartan-ng/helm/input'; import { SessionSummary, SignInProof } from '../../core/auth-api'; import { formatModified } from '../../core/date-format'; +import { t } from '../../core/i18n'; import { describeAgent } from '../../core/signed-in-at'; +import { PasswordField, UsernameHint } from '../../shared/password-field'; import { ProviderRow, SecurityStore } from '../../stores/security.store'; import { UserStore } from '../../stores/user.store'; import { Proof } from './proof'; @@ -32,13 +35,13 @@ const MIN_PASSWORD = 8; /** How you sign in and where you're signed in. The rows say the current state; every change happens in a dialog that asks for proof the way this account can give it. */ @Component({ selector: 'app-security-card', - imports: [NgIcon, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, Proof], + imports: [NgIcon, TranslatePipe, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, PasswordField, UsernameHint, Proof], providers: [provideIcons({ lucideArrowLeft, lucideKeyRound, lucidePlus })], template: `
-

Security

-

How you sign in to Schuly, and where you're signed in.

+

{{ 'settings.security.title' | translate }}

+

{{ 'settings.security.description' | translate }}

@if (security.notice() && !dialog()) { @@ -55,41 +58,41 @@ const MIN_PASSWORD = 8;
  • - Password + {{ 'settings.security.password.label' | translate }} - {{ user.hasPassword() ? 'Set' : 'Not set · you sign in with a provider' }} + {{ (user.hasPassword() ? 'settings.security.password.set' : 'settings.security.password.notSet') | translate }}
  • - Two-factor authentication + {{ 'settings.security.twoFactor.label' | translate }} @if (twoFactorOn()) { - On · {{ security.twoFactor()?.recoveryCodesRemaining ?? 0 }} recovery codes left + {{ 'settings.security.twoFactor.on' | translate: { count: security.twoFactor()?.recoveryCodesRemaining ?? 0 } }} } @else { - Off · a code from your phone at each new sign-in + {{ 'settings.security.twoFactor.off' | translate }} }
@if (security.providers().length || security.linked().length) {
-

Sign-in providers

+

{{ 'settings.security.providers.title' | translate }}

@if (security.unlinkedRows().length) { - }
@@ -107,31 +110,31 @@ const MIN_PASSWORD = 8; {{ provider.name }} - + } } @else { -

No provider linked. Link one to sign in without your password.

+

{{ 'settings.security.providers.none' | translate }}

} } @if (security.sessions().length) { -

Devices

+

{{ 'settings.security.devices.title' | translate }}

    @for (session of security.sessions(); track session.id) {
  • {{ deviceName(session) }} - Last active: {{ when(session.lastUsedAt) }} + {{ 'settings.security.devices.lastActive' | translate: { when: when(session.lastUsedAt) } }} @if (session.isCurrent) { - This device + {{ 'settings.security.devices.thisDevice' | translate }} } @else { - + }
  • } @@ -139,8 +142,8 @@ const MIN_PASSWORD = 8; } @if (security.devices().length) {
    - {{ security.devices().length }} {{ security.devices().length === 1 ? 'device skips' : 'devices skip' }} the two-factor code. - + {{ 'settings.security.devices.remembered' | translate: { count: security.devices().length } }} +
    }
@@ -151,35 +154,36 @@ const MIN_PASSWORD = 8; @switch (dialog()?.kind) { @case ('password') { -

{{ user.hasPassword() ? 'Change password' : 'Add a password' }}

+

{{ (user.hasPassword() ? 'settings.security.passwordDialog.changeTitle' : 'settings.security.passwordDialog.addTitle') | translate }}

- {{ user.hasPassword() ? 'Then sign in again with the new one, here and on your other devices.' : 'Then you can also sign in with your email and this password.' }} + {{ (user.hasPassword() ? 'settings.security.passwordDialog.changeDescription' : 'settings.security.passwordDialog.addDescription') | translate }}

- + + @if (proof.mode() !== 'reauth') {
- - -

At least {{ minPassword }} characters. A sentence you'll remember works well.

+ + +

{{ 'settings.security.passwordDialog.newPasswordHint' | translate: { min: minPassword } }}

} @if (security.error()) { } - + } @case ('twoFactor') { -

Set up two-factor authentication

-

Step {{ twoFactorStep() }} of 3 · {{ stepLabel() }}

+

{{ 'settings.security.twoFactorDialog.title' | translate }}

+

{{ 'settings.security.twoFactorDialog.step' | translate: { step: twoFactorStep(), label: stepLabel() } }}

@if (security.recoveryCodes(); as codes) {
-

Save your recovery codes

-

Each one signs you in once if you lose your phone. Keep them somewhere safe, not only on this device. They're only shown now.

+

{{ 'settings.security.twoFactorDialog.codesTitle' | translate }}

+

{{ 'settings.security.twoFactorDialog.codesText' | translate }}

-
    +
      @for (code of codes; track code) {
    • {{ code }}
    • }
    - - + + } @else if (security.setup(); as setup) {
    -

    Scan this with your authenticator app, then type the 6-digit code it shows.

    - QR code for your authenticator app -

    Can't scan? Enter {{ setup.secret }}

    +

    {{ 'settings.security.twoFactorDialog.scan' | translate }}

    + +

    {{ 'settings.security.twoFactorDialog.cantScan' | translate }} {{ setup.secret }}

    - - + +
    @if (security.error()) { } - - + +
    } @else {
    - + + @if (proof.mode() !== 'reauth') {
    } @if (security.error()) { } - - + + } } @case ('twoFactorOff') { -

    Turn off two-factor authentication

    -

    Signing in then needs only your password or provider.

    +

    {{ 'settings.security.twoFactorOff.title' | translate }}

    +

    {{ 'settings.security.twoFactorOff.description' | translate }}

    +
    - - + +
    - + @if (security.error()) { } - - + + } @case ('linkPick') { -

    Link a provider

    -

    Pick one to sign in with, as well as the ones you have.

    +

    {{ 'settings.security.linkPick.title' | translate }}

    +

    {{ 'settings.security.linkPick.description' | translate }}

      @for (provider of security.unlinkedRows(); track provider.key) { @@ -278,35 +284,36 @@ const MIN_PASSWORD = 8; {{ provider.name }} - + }
    - + } @case ('link') { @if (providerOf(dialog()); as provider) { -

    Link {{ provider.name }}

    -

    Then you can sign in with {{ provider.name }} too.

    +

    {{ 'settings.security.link.title' | translate: { name: provider.name } }}

    +

    {{ 'settings.security.link.description' | translate: { name: provider.name } }}

    + @if (twoFactorOn()) {
    - - + +
    } @else { - + } @if (security.error()) { } - - + + } @@ -314,24 +321,25 @@ const MIN_PASSWORD = 8; @case ('unlink') { @if (providerOf(dialog()); as provider) { -

    Unlink {{ provider.name }}?

    -

    You won't be able to sign in with {{ provider.name }} any more, and you'll be signed out everywhere, here too.

    +

    {{ 'settings.security.unlink.title' | translate: { name: provider.name } }}

    +

    {{ 'settings.security.unlink.description' | translate: { name: provider.name } }}

    + @if (twoFactorOn()) {
    - - + +
    } @else { - + } @if (security.error()) { } - - + + } @@ -339,41 +347,41 @@ const MIN_PASSWORD = 8; @case ('lastWayIn') { @if (providerOf(dialog()); as provider) { -

    You can't unlink {{ provider.name }} yet

    -

    It is your only way to sign in.

    +

    {{ 'settings.security.lastWayIn.title' | translate: { name: provider.name } }}

    +

    {{ 'settings.security.lastWayIn.alertTitle' | translate }}

    -

    It's your only way to sign in

    -

    Add a password or link another provider first, otherwise you'd lock yourself out.

    +

    {{ 'settings.security.lastWayIn.alertTitle' | translate }}

    +

    {{ 'settings.security.lastWayIn.alertText' | translate }}

    - - + + } } @case ('signOut') { @if (sessionOf(dialog()); as session) { -

    Sign out {{ deviceName(session) }}?

    -

    It has to sign in again to use Schuly.

    +

    {{ 'settings.security.signOutDialog.title' | translate: { name: deviceName(session) } }}

    +

    {{ 'settings.security.signOutDialog.description' | translate }}

    @if (security.error()) { } - - + + } } @case ('signInAgain') {

    {{ security.signInAgain() }}

    -

    For your safety, changing how you sign in ends every session, including this one.

    +

    {{ 'settings.security.signInAgain.description' | translate }}

    - + } } @@ -396,7 +404,7 @@ export class SecurityCard { protected readonly twoFactorOn = computed(() => !!this.security.twoFactor()?.enabled); protected readonly twoFactorStep = computed(() => (this.security.recoveryCodes() ? 3 : this.security.setup() ? 2 : 1)); - protected readonly stepLabel = computed(() => ["confirm it's you", 'connect your app', 'recovery codes'][this.twoFactorStep() - 1]); + protected readonly stepLabel = computed(() => t(`settings.security.twoFactorDialog.steps.${['proof', 'app', 'codes'][this.twoFactorStep() - 1]}`)); private resumed = false; /** Link and unlink take a code when there is a second factor, otherwise the account's usual proof. */ protected readonly proofReady = computed(() => (this.twoFactorOn() ? this.code().trim().length > 0 : (this.proof()?.ready() ?? false))); @@ -465,6 +473,7 @@ export class SecurityCard { return password ? { currentPassword: password } : {}; } + /** A real form submit, prevented, so password managers see the change and offer to save the new one. */ protected async submitPassword(event: Event): Promise { event.preventDefault(); await this.security.setPassword(this.user.hasPassword() ? this.currentPassword() : null, this.newPassword()); @@ -503,21 +512,25 @@ export class SecurityCard { protected async signOut(session: SessionSummary): Promise { if (await this.security.signOutSession(session.id)) { this.dialog.set(null); - toast.success(`Signed out ${this.deviceName(session)}`); + toast.success(t('settings.security.devices.signedOut', { name: this.deviceName(session) })); } } protected async copy(codes: string[]): Promise { try { await navigator.clipboard.writeText(codes.join('\n')); - toast.success('Recovery codes copied'); + toast.success(t('settings.security.twoFactorDialog.copied')); } catch { - toast.error('Could not copy. Select the codes and copy them yourself.'); + toast.error(t('settings.security.twoFactorDialog.copyFailed')); } } protected deviceName(session: SessionSummary): string { - return describeAgent(session.userAgent); + return describeAgent(session.userAgent, { + unknown: t('settings.security.devices.unknown'), + app: t('settings.security.devices.app'), + on: (browser, system) => t('settings.security.devices.on', { browser, system }), + }); } protected when(value: string): string { diff --git a/src/Schuly.Frontend/src/app/shared/language-select.ts b/src/Schuly.Frontend/src/app/shared/language-select.ts index d6046ed7..9378340c 100644 --- a/src/Schuly.Frontend/src/app/shared/language-select.ts +++ b/src/Schuly.Frontend/src/app/shared/language-select.ts @@ -1,27 +1,43 @@ import { Component, inject, input } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; +import { HlmSelectImports } from '@spartan-ng/helm/select'; import { LanguageStore } from '../core/i18n'; -/** The app language, as a native select: names stay in their own language so anyone finds theirs. */ +let nextId = 0; + +/** + * The app language, in the same select as the rest of the app (Grades' semester picker): a check on the + * current one and keyboard support. Names stay in their own language so anyone finds theirs. + */ @Component({ selector: 'app-language-select', - imports: [TranslatePipe], + imports: [HlmSelectImports, TranslatePipe], + host: { class: 'flex items-center gap-3' }, template: ` - + + + + + + + + @for (language of store.languages; track language.code) { + {{ language.label }} + } + + + `, }) export class LanguageSelect { protected readonly store = inject(LanguageStore); readonly showLabel = input(false); + readonly size = input<'default' | 'sm'>('default'); + protected readonly id = `language-select-${nextId++}`; + + protected readonly label = (code: string | null): string => this.store.languages.find((l) => l.code === code)?.label ?? ''; + + protected choose(code: string | null | undefined): void { + if (code) this.store.use(code as Parameters[0]); + } } diff --git a/src/Schuly.Frontend/src/app/shared/link-step.ts b/src/Schuly.Frontend/src/app/shared/link-step.ts index 0d8cbea6..d2872330 100644 --- a/src/Schuly.Frontend/src/app/shared/link-step.ts +++ b/src/Schuly.Frontend/src/app/shared/link-step.ts @@ -1,3 +1,4 @@ +import { PasswordField } from './password-field'; import { NgTemplateOutlet } from '@angular/common'; import { Component, ElementRef, Injector, afterNextRender, computed, effect, inject, input, linkedSignal, output, signal, untracked } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; @@ -23,7 +24,7 @@ export interface LinkSubmit { */ @Component({ selector: 'app-link-step', - imports: [NgIcon, NgTemplateOutlet, HlmAlertImports, HlmButton, HlmFieldImports, HlmInput, HlmSpinner], + imports: [PasswordField, NgIcon, NgTemplateOutlet, HlmAlertImports, HlmButton, HlmFieldImports, HlmInput, HlmSpinner], providers: [provideIcons({ lucideCircleCheck, lucideCopy, lucideExternalLink, lucideSmartphone, lucideTriangleAlert })], host: { class: 'flex flex-col gap-4' }, template: ` @@ -79,6 +80,17 @@ export interface LinkSubmit { } + } @else if (field.type === 'password') { + } @else { + + `, +}) +export class PasswordField implements FormValueControl { + readonly value = model(''); + readonly touched = model(false); + readonly disabled = input(false); + readonly invalid = input(false); + readonly inputId = input.required(); + /** Defaults to the id, so a manager sees the same stable name every time. */ + readonly fieldName = input(''); + /** For an error the form itself doesn't know about, such as a refused sign-in. */ + readonly markInvalid = input(false); + readonly autocomplete = input.required<'current-password' | 'new-password'>(); + readonly autofocus = input(false); + readonly describedBy = input(''); + protected readonly shown = signal(false); +} + +/** + * The account a password form belongs to, for password managers to match an entry. Visually hidden rather + * than display:none, which some managers skip; never focused and not read out. + */ +@Component({ + selector: 'app-username-hint', + host: { class: 'sr-only' }, + template: ``, +}) +export class UsernameHint { + readonly username = input.required(); +} diff --git a/src/Schuly.Frontend/src/app/stores/security.store.ts b/src/Schuly.Frontend/src/app/stores/security.store.ts index 10f1c9b4..72f3bce6 100644 --- a/src/Schuly.Frontend/src/app/stores/security.store.ts +++ b/src/Schuly.Frontend/src/app/stores/security.store.ts @@ -4,6 +4,7 @@ import { firstValueFrom } from 'rxjs'; import { toDataURL } from 'qrcode'; import { AuthApi, LinkedSignIn, SessionSummary, SignInProof, SignInProvider, TrustedDevice, TwoFactorStatus } from '../core/auth-api'; import { externalError, newLinkState, returnUrl, takePending } from '../core/external-sign-in'; +import { t } from '../core/i18n'; import { AuthStore } from './auth.store'; import { authErrorMessage } from './auth.store'; @@ -83,61 +84,61 @@ export const SecurityStore = signalStore( }; return { - load: () => run(load, 'Could not load your security settings.'), + load: () => run(load, t('settings.security.messages.loadFailed')), clearError: () => patchState(store, { error: null }), /** `currentPassword` null sets a first password. */ setPassword: (currentPassword: string | null, newPassword: string) => run(async () => { await firstValueFrom(api.changePassword(currentPassword, newPassword)); - patchState(store, { signInAgain: currentPassword === null ? 'Your password is added. Sign in again.' : 'Your password is changed. Sign in again with the new one.' }); - }, 'That did not work. Check your current password.'), + patchState(store, { signInAgain: currentPassword === null ? t('settings.security.messages.passwordAdded') : t('settings.security.messages.passwordChanged') }); + }, t('settings.security.messages.checkCurrentPassword')), requestEmailChange: (email: string, currentPassword: string) => run(async () => { await firstValueFrom(api.requestEmailVerification(email, currentPassword)); - patchState(store, { notice: `We sent a confirmation to ${email}. Your email changes once you confirm it.` }); - }, 'That did not work. Check your password.'), + patchState(store, { notice: t('settings.security.messages.emailSent', { email }) }); + }, t('settings.security.messages.checkPassword')), beginTwoFactor: (currentPassword: string | null) => run(async () => { const { secret, uri } = await firstValueFrom(api.beginTwoFactor(currentPassword)); patchState(store, { setup: { secret, qr: await toDataURL(uri, { margin: 1, width: 200 }) } }); - }, 'That did not work. Check your password.'), + }, t('settings.security.messages.checkPassword')), confirmTwoFactor: (code: string) => run(async () => { const { recoveryCodes } = await firstValueFrom(api.confirmTwoFactor(code)); patchState(store, { setup: null, recoveryCodes }); - }, "That code didn't match. Try the current one from your app."), + }, t('settings.security.messages.codeMismatch')), /** After the recovery codes are saved: turning the second factor on ended every session. */ - finishTwoFactor: () => patchState(store, { recoveryCodes: null, signInAgain: 'Two-factor authentication is on. Sign in again with a code from your app.' }), + finishTwoFactor: () => patchState(store, { recoveryCodes: null, signInAgain: t('settings.security.messages.twoFactorOn') }), disableTwoFactor: (proof: string, currentPassword: string | null) => run(async () => { await firstValueFrom(api.disableTwoFactor(proof, currentPassword)); - patchState(store, { signInAgain: 'Two-factor authentication is off. Sign in again.' }); - }, "That code or password didn't match."), + patchState(store, { signInAgain: t('settings.security.messages.twoFactorOff') }); + }, t('settings.security.messages.codeOrPassword')), linkProvider: (provider: string, proof: SignInProof) => run(async () => { const { authorization_url } = await firstValueFrom(api.linkProvider(provider, returnUrl('/settings'), newLinkState(), proof)); location.assign(authorization_url); - }, "That code or password didn't match."), + }, t('settings.security.messages.codeOrPassword')), unlinkProvider: (provider: string, proof: SignInProof) => run(async () => { await firstValueFrom(api.unlinkProvider(provider, proof)); - patchState(store, { signInAgain: 'The provider is unlinked. Sign in again.' }); - }, 'That did not work. Check your code or password, and keep at least one way to sign in.'), + patchState(store, { signInAgain: t('settings.security.messages.unlinked') }); + }, t('settings.security.messages.unlinkFailed')), linkFinished: (linked: string | null, failure: string | null, state: string | null) => { const pending = takePending(state); if (failure) patchState(store, { error: externalError(failure) }); - else if (linked && pending) patchState(store, { notice: 'The provider is linked. You can now sign in with it.' }); + else if (linked && pending) patchState(store, { notice: t('settings.security.messages.linked') }); }, signOutSession: (id: string) => run(async () => { await firstValueFrom(api.revokeSession(id)); patchState(store, { sessions: store.sessions().filter((s) => s.id !== id) }); - }, 'Could not sign out that device.'), + }, t('settings.security.messages.signOutFailed')), forgetDevices: () => run(async () => { await firstValueFrom(api.forgetAllDevices()); await load(); - }, 'Could not forget your devices.'), + }, t('settings.security.messages.forgetFailed')), cancelSetup: () => patchState(store, { setup: null, error: null }), continueToSignIn: () => { patchState(store, { recoveryCodes: null, signInAgain: null, setup: null, twoFactor: null, devices: [], sessions: [] }); From 2a0276a896950e28be9d422c4c38dfe38f2ba182 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:19:17 +0100 Subject: [PATCH 5/5] Move to Toamaisutaa 0.15.0 --- .claude/CLAUDE.md | 2 +- src/Schuly.API/Schuly.API.csproj | 6 +++--- src/Schuly.Infrastructure/Schuly.Infrastructure.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 92ac2272..21ef8837 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -79,7 +79,7 @@ Applied at startup by `ApplyMigrations()` in `Program.cs`. ## Auth -- Sign-in is built in via Toamaisutaa (`Toamaisutaa.*` 0.14.0). No external IdP. Tables live in the Schuly DB (migration `AddToamaisutaa`). +- Sign-in is built in via Toamaisutaa (`Toamaisutaa.*` 0.15.0). No external IdP. Tables live in the Schuly DB (migration `AddToamaisutaa`). - Endpoints under `/auth` (register, login, refresh, logout, password, email, `2fa/*`, devices, `external/*` for optional OIDC single sign-on). `/api/*` takes `Authorization: Bearer`. Access tokens live 15 min; refresh tokens rotate on every use. - Web app sends `X-Schuly-Client: web` and gets refresh + device tokens as `HttpOnly` `SameSite=Strict` cookies on `/auth`; other clients get them in the JSON body. - Required secrets: `LocalLogin:SigningKey`, `TwoFactor:EncryptionKey` (base64, 32 bytes, keep stable). Set via user-secrets or env vars. diff --git a/src/Schuly.API/Schuly.API.csproj b/src/Schuly.API/Schuly.API.csproj index 315a7471..995d3b03 100644 --- a/src/Schuly.API/Schuly.API.csproj +++ b/src/Schuly.API/Schuly.API.csproj @@ -34,9 +34,9 @@ - - - + + + diff --git a/src/Schuly.Infrastructure/Schuly.Infrastructure.csproj b/src/Schuly.Infrastructure/Schuly.Infrastructure.csproj index 8da9796a..b28f27e2 100644 --- a/src/Schuly.Infrastructure/Schuly.Infrastructure.csproj +++ b/src/Schuly.Infrastructure/Schuly.Infrastructure.csproj @@ -19,7 +19,7 @@ - +