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/Extensions/AuthenticationExtensions.cs b/src/Schuly.API/Extensions/AuthenticationExtensions.cs index c0101cec..e85c40c6 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; @@ -26,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 => { @@ -38,6 +40,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/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.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.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/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..1510053d 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, IExternalLoginStore externalLogins) : IQueryHandler> { public async ValueTask> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken) { @@ -27,6 +28,12 @@ public async ValueTask> Handle(GetCurrentUserQuery qu var dto = user.ToDto(); dto.ProfilePictureUrl = avatarUrlSigner.ToPublicUserUrl(user.Id, user.ProfilePictureUrl); + var hasLogin = Guid.TryParse(user.ExternalId, out var loginId); + var credential = hasLogin ? await credentials.FindByUserIdAsync(loginId, cancellationToken) : null; + dto.HasPassword = credential is not null; + // 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/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/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..84e5bf83 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; @@ -115,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 }); } @@ -135,8 +150,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 +175,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/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/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..45b1943a --- /dev/null +++ b/src/Schuly.Frontend/src/app/core/signed-in-at.ts @@ -0,0 +1,53 @@ +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'; +} + +/** 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, 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 ? 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 0790e7d6..e714ec74 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()) { +
+
+ + +

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

+
+
+ + +
+
+ } @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(t('auth.confirm.sentToNew')); + document.getElementById('email-code')?.focus(); + } catch (error) { + const status = error instanceof HttpErrorResponse ? error.status : 0; + this.error.set( + status === 409 + ? t('auth.confirm.addressTaken') + : status === 400 + ? t('auth.confirm.addressInvalid') + : status === 401 + ? t('auth.confirm.invalid') + : t('auth.confirm.sendFailed'), + ); + } 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..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() }} + {{ error() }} } - - - -
-
-

{{ '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..8fe6f80b 100644 --- a/src/Schuly.Frontend/src/app/pages/settings/account-card.ts +++ b/src/Schuly.Frontend/src/app/pages/settings/account-card.ts @@ -1,178 +1,90 @@ 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 { lucideLoader2, 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 { HlmSpinner } from '@spartan-ng/helm/spinner'; -import { SecurityStore } from '../../stores/security.store'; +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: [ - FormRoot, - FormField, - NgIcon, - HlmAlertImports, - HlmAvatarImports, - HlmBadge, - HlmButton, - HlmCardImports, - HlmFieldImports, - HlmInput, - HlmSpinner, - ], - providers: [provideIcons({ lucideImageUp, lucideX })], + imports: [NgIcon, HlmAlertImports, HlmAvatarImports, HlmCardImports, HlmDropdownMenuImports, TranslatePipe], + providers: [provideIcons({ lucideLoader2, 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() && user.emailConfirmed()) { + · {{ 'settings.profile.confirmed' | translate }} } - 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]; 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); 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..6006ba71 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'; @@ -16,26 +15,34 @@ 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', 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: '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, HlmBadge, HlmButton, HlmCardImports, HlmSpinner, NgIcon, LinkDialog, ProviderIcon], - providers: [provideIcons({ lucidePlus, lucideRefreshCw, lucideUnplug })], + 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 }}

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

{{ problem }}

@@ -48,57 +55,62 @@ const STATUS: Record + } @else if (store.groups().length) { @for (group of store.groups(); track group.providerId) {
-

+
- {{ 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.' }} +
  • + {{ 'settings.accounts.loadFailed' | translate }} + {{ account.message || ('settings.accounts.noAnswer' | translate) }}
  • } @else { -
  • +
  • {{ account.title }} - @if (account.detail) { - {{ account.detail }} - } - - {{ status(account).label }} - @if (account.message) { - {{ account.message }} + + @if (account.detail) { + {{ account.detail }} + } + + + {{ status(account).label | translate }} + + @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. -

    +

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

    +

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

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

    No schools or accounts linked yet.

    +

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

    } - -
    - -
@@ -145,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(); } } @@ -162,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 new file mode 100644 index 00000000..ca46801a --- /dev/null +++ b/src/Schuly.Frontend/src/app/pages/settings/danger-zone-card.ts @@ -0,0 +1,211 @@ +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'; +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'; + +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, TranslatePipe, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, HlmSpinner, UsernameHint, Proof], + providers: [provideIcons({ lucideDownload, lucideTriangleAlert })], + template: ` +
+
+

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

+

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

+
+
+
    +
  • +
    + {{ 'settings.danger.email.label' | translate }} + {{ 'settings.danger.email.text' | translate }} +
    + +
  • +
  • +
    + {{ 'settings.danger.export.label' | translate }} + {{ 'settings.danger.export.text' | translate }} +
    + +
  • +
  • +
    + {{ 'settings.delete.title' | translate }} + {{ 'settings.danger.delete.text' | translate }} +
    + +
  • +
+
+
+ + + + @switch (dialog()) { + @case ('email') { + +

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

+

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

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

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

+

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

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

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

+

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

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

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

+

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

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

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

+

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

+
+
+ +
+
+ + @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); + /** 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; + + 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..cd1abc6e --- /dev/null +++ b/src/Schuly.Frontend/src/app/pages/settings/proof.ts @@ -0,0 +1,60 @@ +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 { 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. `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, PasswordField, TranslatePipe], + template: ` + @switch (mode()) { + @case ('password') { +
+ + +
+ } + @case ('reauth') { +
+

{{ reason() }}

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

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

+ } +
+ } + } + `, +}) +export class Proof { + private readonly auth = inject(AuthStore); + private readonly security = inject(SecurityStore); + private readonly user = inject(UserStore); + + readonly reason = input.required(); + readonly resume = input.required(); + /** Stable, so password managers recognise the field; only one dialog is open at a time. */ + readonly inputId = input('current-password'); + readonly password = model(''); + + /** 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..ef2e7645 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,414 @@ -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 { TranslatePipe } from '@ngx-translate/core'; +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 { 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'; +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, TranslatePipe, HlmAlertImports, HlmButton, HlmCardImports, HlmDialogImports, HlmFieldImports, HlmInput, PasswordField, UsernameHint, Proof], + providers: [provideIcons({ lucideArrowLeft, lucideKeyRound, lucidePlus })], template: ` -
+
-

Security

-

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

+

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

+

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

-
- @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 -
-
- - +
    +
  • +
    + {{ 'settings.security.password.label' | translate }} + + + {{ (user.hasPassword() ? 'settings.security.password.set' : 'settings.security.password.notSet') | translate }} +
    -
    - - + +
  • +
  • +
    + {{ 'settings.security.twoFactor.label' | translate }} + + + @if (twoFactorOn()) { + {{ 'settings.security.twoFactor.on' | translate: { count: security.twoFactor()?.recoveryCodesRemaining ?? 0 } }} + } @else { + {{ 'settings.security.twoFactor.off' | translate }} + } +
    - -
- + + - + @if (security.providers().length || security.linked().length) { +
+

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

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

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

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

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

+
    + @for (session of security.sessions(); track session.id) { +
  • + + {{ deviceName(session) }} + {{ 'settings.security.devices.lastActive' | translate: { when: when(session.lastUsedAt) } }} + + @if (session.isCurrent) { + + + {{ 'settings.security.devices.thisDevice' | translate }} + + } @else { + + } +
  • } +
+ } + @if (security.devices().length) { +
+ {{ 'settings.security.devices.remembered' | translate: { count: security.devices().length } }} +
+ } +
+
- @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() ? 'settings.security.passwordDialog.changeTitle' : 'settings.security.passwordDialog.addTitle') | translate }}

+

+ {{ (user.hasPassword() ? 'settings.security.passwordDialog.changeDescription' : 'settings.security.passwordDialog.addDescription') | translate }}

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

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

    +
    } -
- - } @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') { + +

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

+

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

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

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

+

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

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

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

+ +

{{ 'settings.security.twoFactorDialog.cantScan' | translate }} {{ 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') { + +

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

+

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

+
+
+
- - + +
- + + @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') { + +

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

    +

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

    +
    +
      + @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) { + +

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

    +

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

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

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

    +

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

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

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

    +

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

    +
    +
    +

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

    +

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

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

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

    +

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

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

{{ security.signInAgain() }}

+

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

+
+ + + + } } - -
+ + `, }) 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(() => 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))); + private readonly waysIn = computed(() => this.security.linkedRows().length + (this.user.hasPassword() ? 1 : 0)); constructor() { const query = inject(ActivatedRoute).snapshot.queryParamMap; @@ -320,80 +416,124 @@ 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; - }, - }, - }, - ); + /** 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()); + } - 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(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(t('settings.security.twoFactorDialog.copied')); + } catch { + toast.error(t('settings.security.twoFactorDialog.copyFailed')); + } + } + + protected deviceName(session: SessionSummary): string { + 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 { + return formatModified(value); + } } 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/auth.store.ts b/src/Schuly.Frontend/src/app/stores/auth.store.ts index 310b8ad6..173976e0 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(() => { @@ -98,6 +103,11 @@ export const AuthStore = signalStore( if (!pending) throw new Error('No email confirmation is waiting for a code.'); await firstValueFrom(api.resendEmailCode(pending.challenge)); }, + async changeEmailCodeAddress(newEmail: string): Promise { + const pending = store.emailChallenge(); + if (!pending) throw new Error('No email confirmation is waiting for a code.'); + handle(await firstValueFrom(api.changeEmailCodeAddress(pending.challenge, newEmail))); + }, cancelEmailChallenge(): void { patchState(store, { emailChallenge: null }); }, @@ -138,6 +148,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..72f3bce6 100644 --- a/src/Schuly.Frontend/src/app/stores/security.store.ts +++ b/src/Schuly.Frontend/src/app/stores/security.store.ts @@ -1,29 +1,40 @@ -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 { t } from '../core/i18n'; import { AuthStore } from './auth.store'; 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,92 +44,104 @@ 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) => + 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: 'Your password is changed. Sign in again with the new one.', - }); - }, 'That did not work. Check your current password.'), - resendVerification: (email: string, currentPassword: string) => + 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 link to ${email}.` }); - }, 'That did not work. Check your password.'), - beginTwoFactor: (currentPassword: string) => + 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.'), + patchState(store, { setup: { secret, qr: await toDataURL(uri, { margin: 1, width: 200 }) } }); + }, t('settings.security.messages.checkPassword')), 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.', - }); - }, "That code didn't match. Try the current one from your app."), - disableTwoFactor: (proof: string, currentPassword: string) => + patchState(store, { setup: null, recoveryCodes }); + }, 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: 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) }); + }, 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: [], - }); + 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.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 @@ - +
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] diff --git a/src/Schuly.Tests/VerifiedSignUpEmailTests.cs b/src/Schuly.Tests/VerifiedSignUpEmailTests.cs new file mode 100644 index 00000000..8789f8d5 --- /dev/null +++ b/src/Schuly.Tests/VerifiedSignUpEmailTests.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Configuration; +using Schuly.API.SignUp; + +namespace Schuly.Tests +{ + public class VerifiedSignUpEmailTests + { + private static ConfigurationManager Config(params (string Key, string Value)[] values) + { + var configuration = new ConfigurationManager(); + configuration.AddInMemoryCollection(values.ToDictionary(v => v.Key, v => (string?)v.Value)); + return configuration; + } + + [Test] + public async Task Every_provider_needs_a_verified_email_to_sign_up_unless_the_hoster_says_otherwise() + { + var configuration = Config( + ("ExternalLogin:Providers:google:Authority", "https://accounts.google.com"), + ("ExternalLogin:Providers:school:Authority", "https://idp.school.example"), + ("ExternalLogin:Providers:school:RequireVerifiedEmailForSignUp", "false")); + + VerifiedSignUpEmail.Apply(configuration); + + await Assert.That(configuration["ExternalLogin:Providers:google:RequireVerifiedEmailForSignUp"]).IsEqualTo("true"); + await Assert.That(configuration["ExternalLogin:Providers:school:RequireVerifiedEmailForSignUp"]).IsEqualTo("false"); + } + + [Test] + public async Task No_providers_adds_nothing() + { + await Assert.That(VerifiedSignUpEmail.Defaults(Config()).Count).IsEqualTo(0); + } + } +}