From d5e21d663fcdf59b8a4dff1b6e6c35b4e027cf49 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 7 Oct 2026 17:23:25 +0200 Subject: [PATCH] =?UTF-8?q?Einladen,=20statt=20sich=20f=C3=BCr=20jemanden?= =?UTF-8?q?=20ein=20Passwort=20auszudenken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen und Passwort selbst und ist danach angemeldet. Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat, einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es versehentlich zu setzen. **Der Link steht genau einmal da.** Gespeichert ist nur sein Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem Reverse Proxy rät er falsch. **Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide „eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da — zwei gleichzeitige Einlösungen ergeben genau ein Konto. **Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem Einlösen kommt ein Bild mit 200 zurück. **Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie, wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite, ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen Administrator bekommt. Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt: abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der Gastgeber rät mit. Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit `NOCASE`. Das Namensrennen war also nie offen. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 36 + .../Flaro.Api/Endpoints/EinladungEndpoints.cs | 195 +++ server/src/Flaro.Api/Program.cs | 1 + server/src/Flaro.Core/Auth/Einladung.cs | 54 + server/src/Flaro.Data/FlaroContext.cs | 21 + .../20261007151411_Einladungen.Designer.cs | 1128 +++++++++++++++++ .../Migrations/20261007151411_Einladungen.cs | 62 + .../Migrations/FlaroContextModelSnapshot.cs | 50 + .../tests/Flaro.Api.Tests/EinladungTests.cs | 263 ++++ web/app/src/api.ts | 42 + web/app/src/main.ts | 20 + web/app/src/views/einladung.ts | 134 ++ web/app/src/views/einstellungen/benutzer.ts | 134 +- 13 files changed, 2137 insertions(+), 3 deletions(-) create mode 100644 server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs create mode 100644 server/src/Flaro.Core/Auth/Einladung.cs create mode 100644 server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs create mode 100644 server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs create mode 100644 server/tests/Flaro.Api.Tests/EinladungTests.cs create mode 100644 web/app/src/views/einladung.ts diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 7baae0b..1baab45 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -803,3 +803,39 @@ public sealed record Meldungseintrag( string ReportedBy, /// null heisst: offen. DateTimeOffset? ResolvedAt); + +/* ---------- Einladungen ---------- */ + +/// Was beim Erstellen eines Einladungslinks gewählt wird. +public sealed record EinladungRequest( + /// Wie viele Tage er gilt. + int Days = 7, + /// Wie oft er sich einlösen lässt. null heisst: unbegrenzt. + int? MaxUses = 1); + +/// Der frisch erstellte Link — der Token steht hier zum einzigen Mal. +/// +/// +/// Die Adresse baut der Client. Der Server weiss nicht, unter welchem +/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er +/// falsch, und ein falscher Einladungslink ist schlimmer als keiner. +/// +public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses); + +/// Eine Einladung in der Verwaltungsliste — ohne Token. +public sealed record Einladungseintrag( + Guid Id, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + int? MaxUses, + int UsedCount, + DateTimeOffset? RevokedAt, + /// Ob sich damit jetzt noch ein Konto anlegen lässt. + bool Valid); + +/// Was die Einladungsseite zeigt, bevor jemand etwas eintippt. +public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt); + +/// Namen und Passwort, die sich der Eingeladene selbst gibt. +public sealed record EinloesenRequest(string UserName, string Password); diff --git a/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs new file mode 100644 index 0000000..65aa1d6 --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs @@ -0,0 +1,195 @@ +using System.Security.Claims; +using Flaro.Core.Auth; +using Flaro.Core.Users; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Endpoints; + +/// Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden. +/// +/// +/// +/// Warum. Ein Konto von Hand anzulegen heisst, sich ein Passwort für +/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal, +/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen +/// und Passwort selbst geben. +/// +/// +/// Zwei Welten in einer Datei. Erstellen, auflisten und zurückziehen +/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein +/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale +/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre +/// ungebremst. +/// +/// +public static class EinladungEndpoints +{ + private const int HoechstensTage = 365; + + public static void MapEinladungen(this IEndpointRouteBuilder app) + { + var verwalten = app.MapGroup("/api/v1/invites") + .RequireAuthorization(FlaroPolicies.Administrator); + + verwalten.MapPost("", async ( + EinladungRequest anfrage, ClaimsPrincipal wer, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + if (Kennung(wer) is not { } ich) return Results.Unauthorized(); + + var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage); + if (anfrage.MaxUses is { } anzahl && anzahl < 1) + return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner."); + + // **Das Geheimnis verlässt den Server genau hier.** Gespeichert + // wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein + // Link mehr bauen, und eine verlorene Einladung wird deshalb nicht + // wiedergefunden, sondern neu gemacht. + var token = Secrets.Create(); + var jetzt = uhr.GetUtcNow(); + var einladung = new Einladung + { + TokenFingerprint = Secrets.Fingerprint(token), + CreatedByUserId = ich, + CreatedAt = jetzt, + ExpiresAt = jetzt.AddDays(tage), + MaxUses = anfrage.MaxUses, + }; + + db.Einladungen.Add(einladung); + await db.SaveChangesAsync(ct); + + return Results.Ok(new EinladungErstellt( + einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses)); + }); + + verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var jetzt = uhr.GetUtcNow(); + var roh = await db.Einladungen.AsNoTracking() + .OrderByDescending(e => e.CreatedAt) + .Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id, + (e, u) => new { E = e, u.UserName }) + .ToListAsync(ct); + + return Results.Ok(roh.Select(z => new Einladungseintrag( + z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt, + z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList()); + }); + + // **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse, + // hinterher nicht mehr zu wissen, dass es den Link je gab — und genau + // das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht. + verwalten.MapDelete("/{id:guid}", async ( + Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct); + if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr."); + + einladung.RevokedAt ??= uhr.GetUtcNow(); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + + /* ---------- Was ohne Konto erreichbar sein muss ---------- */ + + app.MapGet("/api/v1/invites/{token}", async ( + string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var gefunden = await Suchen(db, token, ct); + if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand; + + var name = await db.Users.AsNoTracking() + .Where(u => u.Id == gefunden!.CreatedByUserId) + .Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? ""; + + var servername = await db.Settings.AsNoTracking() + .Where(s => s.Key == SystemEndpoints.NameSchlüssel) + .Select(s => s.Value).FirstOrDefaultAsync(ct); + + return Results.Ok(new Einladungsschau( + string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername, + name, gefunden!.ExpiresAt)); + }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); + + app.MapPost("/api/v1/invites/{token}/redeem", async ( + string token, EinloesenRequest anfrage, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var name = anfrage.UserName?.Trim() ?? ""; + if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8) + { + return Einwand.Bitte( + "Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."); + } + + var jetzt = uhr.GetUtcNow(); + var gefunden = await Suchen(db, token, ct); + if (Pruefen(gefunden, jetzt) is { } einwand) return einwand; + + // **Verbrauchen und anlegen gehören zusammen.** + // + // Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, + // läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten + // beide ein Konto. Der Zähler wird deshalb mit einer Bedingung + // hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei + // keine Zeile trifft, war der Zweite. + // + // Und das Ganze in einer Transaktion: scheitert das Anlegen am + // Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger + // Link an einem Tippfehler verbrannt. + await using var geschaeft = await db.Database.BeginTransactionAsync(ct); + + var verbraucht = await db.Einladungen + .Where(e => e.Id == gefunden!.Id + && e.RevokedAt == null + && e.ExpiresAt > jetzt + && (e.MaxUses == null || e.UsedCount < e.MaxUses)) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct); + + if (verbraucht != 1) + return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."); + + if (await db.Users.AnyAsync(u => u.UserName == name, ct)) + return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen."); + + db.Users.Add(new User + { + UserName = name, + PasswordHash = PasswordHasher.Hash(anfrage.Password!), + IsAdministrator = false, + }); + + await db.SaveChangesAsync(ct); + await geschaeft.CommitAsync(ct); + return Results.NoContent(); + }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); + } + + private static Task Suchen(FlaroContext db, string token, CancellationToken ct) => + db.Einladungen.FirstOrDefaultAsync( + e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct); + + /// Der Grund, warum ein Link nicht gilt — oder null. + /// + /// + /// Unterschiedliche Sätze, mit Absicht. Alles in ein „gilt nicht" + /// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit + /// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird, + /// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat — + /// sonst fragt er nach, und der Gastgeber rät mit. + /// + private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch + { + null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."), + { RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."), + var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."), + var e when e.MaxUses is { } h && e.UsedCount >= h => + Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."), + _ => null, + }; + + private static Guid? Kennung(ClaimsPrincipal wer) => + Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 8ab5824..5016467 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -272,6 +272,7 @@ app.MapArtwork(); app.MapProfil(); app.MapFreigabe(); app.MapMeldungen(); +app.MapEinladungen(); // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die diff --git a/server/src/Flaro.Core/Auth/Einladung.cs b/server/src/Flaro.Core/Auth/Einladung.cs new file mode 100644 index 0000000..f75939a --- /dev/null +++ b/server/src/Flaro.Core/Auth/Einladung.cs @@ -0,0 +1,54 @@ +namespace Flaro.Core.Auth; + +/// Ein Link, mit dem sich jemand selbst ein Konto anlegt. +/// +/// +/// +/// Warum nicht der vorhandene . Der ist ein +/// Gerätecode: fünf Minuten Leben, ein Gerätename daneben, und sein +/// ApprovedByUserId zeigt auf ein Konto, das es schon gibt — jemand, +/// der angemeldet ist, winkt ein zweites Gerät durch. Eine Einladung ist das +/// Gegenteil: erlaubt wird vorab, und wer sie einlöst, hat noch gar kein +/// Konto. Dieselbe Tabelle für beides hiesse, die Hälfte ihrer Spalten +/// wechselweise leer zu lassen. +/// +/// +/// Übernommen ist die Geheimnisdisziplin. Wie bei +/// liegt hier nur der Fingerabdruck; den Link +/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die +/// Datenbank liest, kann daraus keine Einladung bauen. +/// +/// +/// Keine Rolle. Wer eingeladen wird, ist gewöhnlicher Benutzer. Ein +/// Feld, über das ein Link Adminrechte verschenken könnte, gibt es nicht — +/// und damit auch keinen Weg, es versehentlich zu setzen. Administratoren +/// legt man von Hand an, und das ist selten genug. +/// +/// +public sealed class Einladung +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + + /// Siehe — nie der Link selbst. + public required string TokenFingerprint { get; init; } + + public required Guid CreatedByUserId { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } + + /// Wie oft er gilt. null heisst: unbegrenzt. + public int? MaxUses { get; init; } + + public int UsedCount { get; set; } + + /// Von Hand zurückgezogen — null heisst: gilt noch. + public DateTimeOffset? RevokedAt { get; set; } + + /// Ob sich damit jetzt noch ein Konto anlegen lässt. + public bool Gilt(DateTimeOffset jetzt) => + RevokedAt is null + && jetzt < ExpiresAt + && (MaxUses is not { } hoechstens || UsedCount < hoechstens); +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 79c6bfb..0d46b7e 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -55,6 +55,9 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Users => Set(); public DbSet DeviceTokens => Set(); public DbSet AuthCodes => Set(); + + /// Links, mit denen sich jemand selbst ein Konto anlegt. + public DbSet Einladungen => Set(); public DbSet Libraries => Set(); public DbSet Settings => Set(); @@ -129,6 +132,24 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.DeviceId).HasMaxLength(128); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + + // Gesucht wird nur über den Fingerabdruck — und eindeutig, damit + // zwei Links niemals dasselbe Konto füttern können. + e.HasIndex(x => x.TokenFingerprint).IsUnique(); + e.Property(x => x.TokenFingerprint).HasMaxLength(64); + + // Die Liste in der Verwaltung: die neuesten zuerst. + e.HasIndex(x => x.CreatedAt); + + // Wer einlädt und später gelöscht wird, nimmt seine offenen + // Einladungen mit. Ein Link ohne Absender ist keiner. + e.HasOne().WithMany().HasForeignKey(x => x.CreatedByUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs new file mode 100644 index 0000000..51603df --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs @@ -0,0 +1,1128 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261007151411_Einladungen")] + partial class Einladungen + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs new file mode 100644 index 0000000..750d1f9 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Einladungen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Einladungen", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TokenFingerprint = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + ExpiresAt = table.Column(type: "INTEGER", nullable: false), + MaxUses = table.Column(type: "INTEGER", nullable: true), + UsedCount = table.Column(type: "INTEGER", nullable: false), + RevokedAt = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Einladungen", x => x.Id); + table.ForeignKey( + name: "FK_Einladungen_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_CreatedAt", + table: "Einladungen", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_CreatedByUserId", + table: "Einladungen", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_TokenFingerprint", + table: "Einladungen", + column: "TokenFingerprint", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Einladungen"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 316a596..789f8b7 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -108,6 +108,47 @@ namespace Flaro.Data.Migrations b.ToTable("DeviceTokens"); }); + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => { b.Property("Id") @@ -920,6 +961,15 @@ namespace Flaro.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Flaro.Core.Library.Credit", b => { b.HasOne("Flaro.Core.Library.Movie", null) diff --git a/server/tests/Flaro.Api.Tests/EinladungTests.cs b/server/tests/Flaro.Api.Tests/EinladungTests.cs new file mode 100644 index 0000000..c58e6f7 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/EinladungTests.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Einladen statt ein Konto für jemanden erfinden. +/// +/// +/// Der Test, der hier wirklich zählt, ist der mit den zwei gleichzeitigen +/// Einlösungen. Ein einmaliger Link, zweimal im selben Augenblick +/// geöffnet, ist der einzige Fall, den man von Hand nie findet — und der +/// einzige, bei dem ein falscher Entwurf zwei Konten statt einem anlegt. +/// +public class EinladungTests +{ + private static async Task Einladen( + HttpClient alsAdmin, int tage = 7, int? wieOft = 1) + { + var antwort = await alsAdmin.PostAsJsonAsync("/api/v1/invites", + new EinladungRequest(tage, wieOft)); + antwort.EnsureSuccessStatusCode(); + return (await antwort.Content.ReadFromJsonAsync())!; + } + + private static Task Einloesen( + HttpClient ohne, string token, string name, string passwort = "einlangespasswort") => + ohne.PostAsJsonAsync($"/api/v1/invites/{token}/redeem", new EinloesenRequest(name, passwort)); + + [Test] + public async Task Wer_den_Link_hat_legt_sich_ein_Konto_an() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var fremder = server.OhneGedaechtnis(); + var schau = await fremder.GetFromJsonAsync( + $"/api/v1/invites/{einladung.Token}"); + var angelegt = await Einloesen(fremder, einladung.Token, "nachbarin"); + + var konten = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + + Assert.Multiple(() => + { + Assert.That(schau!.InvitedBy, Is.EqualTo("guido")); + Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(konten!.Select(k => k.UserName), Does.Contain("nachbarin")); + }); + } + + [Test] + public async Task Wer_eingeladen_wird_ist_kein_Administrator() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin"); + + var anmeldung = await server.SignIn("nachbarin", "einlangespasswort", "Prüfgerät", "p1"); + var alsGast = server.AlsAngemeldeter(anmeldung.Token); + var verwaltung = await alsGast.GetAsync("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(anmeldung.User.IsAdministrator, Is.False); + Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + [Test] + public async Task Ein_eingeloester_Link_gilt_nicht_zweimal() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + await Einloesen(server.OhneGedaechtnis(), einladung.Token, "erste"); + var zweite = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zweite"); + + Assert.That(zweite.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(await zweite.Content.ReadAsStringAsync(), Does.Contain("schon benutzt")); + } + + /// Zwei im selben Augenblick — und trotzdem nur ein Konto. + [Test] + public async Task Zwei_gleichzeitige_Einloesungen_ergeben_ein_Konto() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var beide = await Task.WhenAll( + Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"), + Einloesen(server.OhneGedaechtnis(), einladung.Token, "andere")); + + var konten = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + var dazu = konten!.Count(k => k.UserName is "eine" or "andere"); + + Assert.Multiple(() => + { + Assert.That(beide.Count(a => a.IsSuccessStatusCode), Is.EqualTo(1), + "Genau eine Einlösung darf durchkommen."); + Assert.That(dazu, Is.EqualTo(1), "Und genau ein Konto darf entstanden sein."); + }); + } + + [Test] + public async Task Mehrfachlinks_gelten_so_oft_wie_angegeben() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin, wieOft: 2); + + var eine = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"); + var zwei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zwei"); + var drei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "drei"); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(eine.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(zwei.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(drei.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(liste![0].UsedCount, Is.EqualTo(2)); + Assert.That(liste[0].Valid, Is.False); + }); + } + + [Test] + public async Task Ein_zurueckgezogener_Link_wird_abgewiesen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + (await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode(); + + var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet"); + var schau = await server.OhneGedaechtnis().GetAsync($"/api/v1/invites/{einladung.Token}"); + + Assert.Multiple(() => + { + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("zurückgezogen")); + } + + [Test] + public async Task Ein_abgelaufener_Link_wird_abgewiesen() + { + var uhr = new Stelluhr(DateTimeOffset.UtcNow); + using var server = new FlaroServer(dienste => dienste.AddSingleton(uhr)); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin, tage: 1); + + uhr.Weiter(TimeSpan.FromDays(2)); + + var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet"); + + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("abgelaufen")); + } + + [Test] + public async Task Einen_Link_den_es_nicht_gibt_kennt_niemand() + { + using var server = new FlaroServer(); + await server.SetUpAndSignIn(); + + var versuch = await Einloesen(server.OhneGedaechtnis(), "ausgedacht", "wer"); + var schau = await server.OhneGedaechtnis().GetAsync("/api/v1/invites/ausgedacht"); + + Assert.Multiple(() => + { + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + }); + } + + [Test] + public async Task Einladen_darf_nur_die_Verwaltung() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + var erstellen = await gast.PostAsJsonAsync("/api/v1/invites", new EinladungRequest()); + var lesen = await gast.GetAsync("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(erstellen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That(lesen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// Der Link steht genau einmal da — danach nicht einmal mehr dem Admin. + [Test] + public async Task Der_Token_steht_nur_im_Erstellen_nicht_in_der_Liste() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var roh = await (await alsAdmin.GetAsync("/api/v1/invites")).Content.ReadAsStringAsync(); + + Assert.That(einladung.Token, Is.Not.Empty); + Assert.That(roh, Does.Not.Contain(einladung.Token), + "Gespeichert ist nur der Fingerabdruck — aus der Liste darf kein Link zu bauen sein."); + } + + /// Ein Tippfehler im Namen darf den Link nicht verbrennen. + [Test] + public async Task Ein_vergebener_Name_verbraucht_die_Einladung_nicht() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var belegt = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "guido"); + var danach = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin"); + + Assert.Multiple(() => + { + Assert.That(belegt.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NoContent), + "Der Link muss nach dem Fehlgriff noch gelten."); + }); + } + + [Test] + public async Task Ohne_langes_Passwort_geht_es_nicht() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var kurz = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "wer", "1234"); + var leer = await Einloesen(server.OhneGedaechtnis(), einladung.Token, " "); + + Assert.Multiple(() => + { + Assert.That(kurz.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 9491f75..1a7d4e5 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1081,6 +1081,48 @@ export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }), }); +/* ---------- Einladungen ---------- */ + +export type Einladung = { + id: string; + createdBy: string; + createdAt: string; + expiresAt: string; + /** `null` heisst: unbegrenzt oft. */ + maxUses: number | null; + usedCount: number; + revokedAt: string | null; + /** Ob sich damit jetzt noch ein Konto anlegen lässt. */ + valid: boolean; +}; + +/** Was die Einladungsseite zeigt, bevor jemand etwas eintippt. */ +export type Einladungsschau = { serverName: string; invitedBy: string; expiresAt: string }; + +/** Legt einen Einladungslink an. Der Token steht nur in dieser einen Antwort. */ +export const einladungAnlegen = (tage: number, wieOft: number | null) => + anfrage<{ id: string; token: string; expiresAt: string; maxUses: number | null }>('/invites', { + method: 'POST', + body: JSON.stringify({ days: tage, maxUses: wieOft }), + }); + +export const einladungen = () => anfrage('/invites'); + +/** Zieht ihn zurück. Die Zeile bleibt — man will wissen, dass es ihn gab. */ +export const einladungZurueckziehen = (id: string) => + anfrage(`/invites/${id}`, { method: 'DELETE' }); + +/** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */ +export const einladungAnsehen = (token: string) => + anfrage(`/invites/${encodeURIComponent(token)}`); + +/** Legt das Konto an. Angemeldet wird danach gewöhnlich über `anmelden`. */ +export const einladungEinloesen = (token: string, name: string, passwort: string) => + anfrage(`/invites/${encodeURIComponent(token)}/redeem`, { + method: 'POST', + body: JSON.stringify({ userName: name, password: passwort }), + }); + export const metadatenEinstellung = () => anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata'); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 7c1d33c..c484a7a 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -6,6 +6,7 @@ import { } from './api'; import { icons } from './icons'; import { einrichtung } from './views/setup'; +import { einladungsseite } from './views/einladung'; import { einhaengen, ablegen, vergessen } from './ansichten'; import { anmeldung } from './views/login'; import { startseite } from './views/home'; @@ -38,6 +39,25 @@ async function hochfahren(): Promise { serverName = auskunft.serverName ?? 'Flaro'; if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; } + + // **Die Einladung muss hierher, nicht in den Router.** Beide Zweige + // ringsum kehren zurück, bevor `zeichnen()` je läuft — ein Ausgeloggter + // mit Einladungslink sähe sonst schlicht die Anmeldekarte, und nach dem + // Anmelden landete er auf der Startseite, ohne je erfahren zu haben, + // wozu er eingeladen war. + // + // Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen + // Administrator bekommt; vor der Anmeldung, weil genau die ja fehlt. + const [erstes, zweites] = location.hash.replace(/^#\/?/, '').split('/'); + if (erstes === 'einladung' && zweites) { + zeigeAnmeldung(einladungsseite(decodeURIComponent(zweites), () => { + // Der Hash muss weg, sonst landet das Neuladen wieder hier. + location.hash = '#/'; + location.reload(); + })); + return; + } + if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; } anmeldeflaeche.classList.add('hidden'); diff --git a/web/app/src/views/einladung.ts b/web/app/src/views/einladung.ts new file mode 100644 index 0000000..9058cff --- /dev/null +++ b/web/app/src/views/einladung.ts @@ -0,0 +1,134 @@ +import { + einladungAnsehen, einladungEinloesen, anmelden, abmelden, sitzung, angemeldet, + type Einladungsschau, +} from '../api'; +import { knopf, feld, fehlerzeile, meldung, esc } from '../util'; + +/** Die Seite hinter einem Einladungslink. + * + * **Die einzige Stelle neben dem Einrichten, an der jemand ohne Anmeldung + * etwas anlegt** — und sie verschliesst sich selbst: der Link gilt begrenzt + * oft und begrenzt lange, und der Server prüft beides noch einmal. + * + * **Name und Passwort gibt sich der Eingeladene selbst.** Das ist der ganze + * Sinn: ein Konto von Hand anzulegen hiesse, sich ein Passwort für einen + * anderen auszudenken und es ihm über denselben Kanal durchzusagen, über den + * er auch den Link bekommen hätte. + */ +export function einladungsseite(token: string, fertig: () => void): HTMLElement { + const karte = document.createElement('div'); + karte.className = 'login-card'; + karte.innerHTML = '
'; + + // **Wer schon angemeldet ist, bekommt kein Formular.** Ein zweites Konto + // für denselben Menschen ist fast nie gemeint; viel wahrscheinlicher ist + // der Gastgeber, der seinen eigenen Link anklickt, um zu sehen, ob er geht. + if (angemeldet()) { + karte.innerHTML = ''; + const h = document.createElement('h1'); + h.textContent = 'Einladung'; + const satz = document.createElement('p'); + satz.className = 'hinweis'; + satz.innerHTML = `Du bist schon als ${esc(sitzung.wer?.userName ?? '')} ` + + 'angemeldet. Für ein neues Konto musst du dich zuerst abmelden.'; + + const weiter = knopf('Weiter zu Flaro', 'btn btn-primary'); + weiter.onclick = fertig; + + const wechseln = knopf('Abmelden und Konto anlegen', 'btn'); + wechseln.onclick = () => { abmelden(); location.reload(); }; + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.append(weiter, wechseln); + karte.append(h, satz, reihe); + return karte; + } + + void laden(); + return karte; + + async function laden(): Promise { + let schau: Einladungsschau; + try { + schau = await einladungAnsehen(token); + } catch (fehler) { + // **Der Grund steht da.** „Gilt nicht" wäre verschwiegener, aber wer + // einen Link bekommen hat und abgewiesen wird, muss wissen, ob er zu + // spät ist oder ob jemand ihn zurückgezogen hat — sonst fragt er nach, + // und der Gastgeber rät mit. + karte.innerHTML = '

Einladung

'; + karte.appendChild(fehlerzeile(meldung(fehler))); + const zurueck = knopf('Zur Anmeldung', 'btn'); + zurueck.onclick = fertig; + karte.appendChild(zurueck); + return; + } + + karte.innerHTML = ''; + + const titel = document.createElement('h1'); + titel.textContent = `Willkommen bei ${schau.serverName}`; + + const text = document.createElement('p'); + text.className = 'hinweis'; + const bis = new Date(schau.expiresAt) + .toLocaleDateString('de-CH', { dateStyle: 'medium' }); + text.innerHTML = `${esc(schau.invitedBy)} hat dich eingeladen. ` + + `Wähle dir Namen und Passwort — der Link gilt noch bis zum ${esc(bis)}.`; + + const name = feld('Benutzername'); + name.autocomplete = 'username'; + const passwort = feld('Passwort, mindestens 8 Zeichen', 'password'); + passwort.autocomplete = 'new-password'; + + const bleiben = document.createElement('label'); + bleiben.className = 'bleiben'; + const haken = document.createElement('input'); + haken.type = 'checkbox'; + haken.checked = true; + bleiben.append(haken, document.createTextNode(' Angemeldet bleiben')); + + const los = knopf('Konto anlegen', 'btn btn-primary'); + + karte.append(titel, text, name, passwort, bleiben, los); + + async function absenden(): Promise { + karte.querySelector('.fehler')?.remove(); + + // Hier schon, damit der Link nicht für einen Tippfehler hinausgeht. + if (name.value.trim().length === 0 || passwort.value.length < 8) { + karte.appendChild(fehlerzeile( + 'Benutzername darf nicht leer sein, das Passwort braucht mindestens acht Zeichen.')); + return; + } + + los.disabled = true; + try { + await einladungEinloesen(token, name.value.trim(), passwort.value); + } catch (fehler) { + karte.appendChild(fehlerzeile(meldung(fehler))); + los.disabled = false; + return; + } + + // **Angelegt ist nicht angemeldet.** Scheitert dieser zweite Schritt, + // steht das Konto trotzdem — dann ist der Weg die gewöhnliche + // Anmeldung, und genau das muss dastehen. + try { + await anmelden(name.value.trim(), passwort.value, haken.checked); + fertig(); + } catch { + karte.appendChild(fehlerzeile( + 'Das Konto steht, aber die Anmeldung hat nicht geklappt. ' + + 'Melde dich mit deinem neuen Namen und Passwort an.')); + los.disabled = false; + } + } + + los.onclick = () => void absenden(); + name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); }; + passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); }; + setTimeout(() => name.focus(), 50); + } +} diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts index e62feea..6c2a220 100644 --- a/web/app/src/views/einstellungen/benutzer.ts +++ b/web/app/src/views/einstellungen/benutzer.ts @@ -1,5 +1,8 @@ -import { benutzer, benutzerAnlegen } from '../../api'; -import { esc, knopf, fehlerzeile, meldung } from '../../util'; +import { + benutzer, benutzerAnlegen, + einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung, +} from '../../api'; +import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util'; import { dialog } from '../../dialog'; import { abschnitt } from './teile'; @@ -38,13 +41,130 @@ export function benutzerbereich(): HTMLElement { }, }); - bereich.appendChild(abschnitt('Benutzer', [anlegen])); + // **Einladen statt ein Konto erfinden.** Ein Konto von Hand anzulegen + // heisst, sich für jemand anderen ein Passwort auszudenken und es ihm + // durchzusagen — über denselben Kanal, über den auch der Link ginge. + const einladen = knopf('Einladen…', 'btn'); + einladen.onclick = () => dialog({ + titel: 'Jemanden einladen', + text: 'Wer den Link öffnet, wählt Benutzername und Passwort selbst und ist ' + + 'danach angemeldet. Administrator wird dabei niemand.', + felder: [ + { + name: 'tage', beschriftung: 'Gültig', + auswahl: [ + { wert: '1', text: 'Einen Tag' }, + { wert: '7', text: 'Eine Woche' }, + { wert: '30', text: 'Einen Monat' }, + ], + wert: '7', + }, + { + name: 'wieoft', beschriftung: 'Benutzbar', + auswahl: [ + { wert: '1', text: 'Einmal' }, + { wert: '5', text: 'Fünfmal' }, + { wert: '', text: 'Unbegrenzt' }, + ], + wert: '1', + hinweis: 'Ein mehrfacher Link ist bequem für eine Familie — und wer ihn ' + + 'weitergibt, lädt jemanden ein, den du nicht gemeint hast.', + }, + ], + bestaetigen: 'Link erstellen', + tun: async wert => { + const wieOft = wert('wieoft') === '' ? null : Number(wert('wieoft')); + const neu = await einladungAnlegen(Number(wert('tage')), wieOft); + await laden(); + linkZeigen(neu.token); + }, + }); + + bereich.appendChild(abschnitt('Benutzer', [einladen, anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); + + // **Bleibt weg, solange niemand eingeladen wurde.** Eine Überschrift über + // einer leeren Liste erklärt nichts; den Weg dorthin zeigt der Knopf oben. + const einladungskopf = abschnitt('Einladungen'); + einladungskopf.classList.add('hidden'); + const einladungsliste = document.createElement('div'); + einladungsliste.className = 'einstellungen hidden'; + bereich.append(einladungskopf, einladungsliste); + void laden(); + /** Der fertige Link, genau einmal. + * + * **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem + * Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er + * falsch, und ein falscher Einladungslink ist schlimmer als keiner. */ + function linkZeigen(token: string): void { + const adresse = `${location.origin}${location.pathname}#/einladung/${token}`; + + dialog({ + titel: 'Einladung fertig', + text: 'Schick diesen Link weiter. Er steht nur jetzt da — gespeichert ist ' + + 'nur sein Fingerabdruck, aus der Liste lässt er sich nicht noch ' + + 'einmal herstellen.', + felder: [{ name: 'link', beschriftung: 'Link', wert: adresse }], + bestaetigen: 'Kopieren', + tun: async () => { + try { + await navigator.clipboard.writeText(adresse); + hinweisZeigen('Link kopiert.'); + } catch { + // Ohne Zwischenablage — etwa ohne HTTPS — bleibt das Feld zum + // Markieren stehen, und das ist kein Fehler wert. + hinweisZeigen('Kopieren ging nicht. Der Link steht im Feld.', 'fehler'); + } + }, + }); + } + + /* ---------- Offene Einladungen ---------- */ + + function einladungszeile(e: Einladung): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const benutzt = e.maxUses === null + ? `${e.usedCount}× benutzt, unbegrenzt` + : `${e.usedCount} von ${e.maxUses} benutzt`; + const stand = e.revokedAt ? 'Zurückgezogen' + : e.valid ? `Gültig bis ${datum(e.expiresAt)}` + : 'Abgelaufen oder aufgebraucht'; + + const zeile = document.createElement('div'); + zeile.className = 'zeile'; + zeile.style.flex = '1'; + zeile.innerHTML = `${esc(stand)}` + + `${esc(`${benutzt} · von ${e.createdBy} · ${datum(e.createdAt)}`)}`; + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.appendChild(zeile); + + if (!e.revokedAt) { + const weg = knopf('Zurückziehen', 'btn'); + weg.onclick = async () => { + weg.disabled = true; + try { await einladungZurueckziehen(e.id); await laden(); } + catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); } + }; + reihe.appendChild(weg); + } + + d.appendChild(reihe); + return d; + } + + function datum(wert: string): string { + return new Date(wert).toLocaleDateString('de-CH', { dateStyle: 'medium' }); + } + async function laden(): Promise { // **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** // Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — @@ -64,6 +184,14 @@ export function benutzerbereich(): HTMLElement { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); } + + try { + const offene = await einladungen(); + einladungsliste.innerHTML = ''; + einladungskopf.classList.toggle('hidden', offene.length === 0); + einladungsliste.classList.toggle('hidden', offene.length === 0); + offene.forEach(e => einladungsliste.appendChild(einladungszeile(e))); + } catch { /* Ein Abschnitt, der nicht lädt, fehlt eben. */ } } return bereich;