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;