diff options
| author | AlfaPC11 | 2026-07-28 21:38:04 +0300 |
|---|---|---|
| committer | AlfaPC11 | 2026-07-28 21:38:04 +0300 |
| commit | 3e726a87c9ae60aea3403e12c8a0aab3870942df (patch) | |
| tree | 09a55fb7328e8ca7eb29f43a5e930de557104c13 | |
| parent | 0c9ea445a277c23aeb7156578c623ac502591499 (diff) | |
| download | website-3e726a87c9ae60aea3403e12c8a0aab3870942df.tar.gz | |
require verification after Google auth and add account deletion
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | backend/Auth/AuthEndpoints.cs | 60 | ||||
| -rw-r--r-- | backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.Designer.cs | 383 | ||||
| -rw-r--r-- | backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.cs | 68 | ||||
| -rw-r--r-- | backend/Data/Migrations/RegistryDbContextModelSnapshot.cs | 20 | ||||
| -rw-r--r-- | backend/Data/RegistryDbContext.cs | 8 | ||||
| -rw-r--r-- | frontend/public/scripts/auth-flow.v2.js (renamed from frontend/public/scripts/auth-flow.v1.js) | 15 | ||||
| -rw-r--r-- | frontend/public/scripts/dashboard.v1.js (renamed from frontend/src/scripts/dashboard.ts) | 89 | ||||
| -rw-r--r-- | frontend/src/components/AuthPanel.astro | 2 | ||||
| -rw-r--r-- | frontend/src/pages/repo/dashboard/index.astro | 36 | ||||
| -rw-r--r-- | frontend/src/styles/global.css | 21 |
11 files changed, 651 insertions, 55 deletions
@@ -19,7 +19,8 @@ This repository contains the public web surface for the X# programming language The registry account dashboard is available at [`repo.xsharp-lang.xyz/dashboard`](https://repo.xsharp-lang.xyz/dashboard/). Package publication remains closed while the compiler package contract is completed. Registry tokens follow the Cargo model: the plaintext is returned once, -only a SHA-256 digest is retained, and a user can revoke each token independently. +only a SHA-256 digest is retained, and a user can revoke each token independently. Account deletion permanently +removes the Identity account, external logins, verification records, and registry tokens. The PostgreSQL provider is selected solely by `ConnectionStrings__Registry`; production can use a Supabase PostgreSQL connection without exposing database or service credentials to the browser. Google OAuth client credentials, the @@ -29,6 +30,7 @@ The Google OAuth web client callback is `https://repo.xsharp-lang.xyz/api/v1/auth/google/callback`. Configure it through `Authentication__Google__ClientId` and `Authentication__Google__ClientSecret`. Until both values exist, the frontend keeps the Google button visible but disabled and email/password authentication remains available. +Every completed Google authorization still requires the short-lived X# email code before a registry session is issued. ## Requirements diff --git a/backend/Auth/AuthEndpoints.cs b/backend/Auth/AuthEndpoints.cs index 7ecb376..e4ea645 100644 --- a/backend/Auth/AuthEndpoints.cs +++ b/backend/Auth/AuthEndpoints.cs @@ -14,6 +14,7 @@ internal static class AuthEndpoints { private const string DashboardUrl = "https://repo.xsharp-lang.xyz/dashboard/"; private const string LoginUrl = "https://repo.xsharp-lang.xyz/login/"; + private const string RegisterUrl = "https://repo.xsharp-lang.xyz/register/"; public static void MapAuthEndpoints(this WebApplication app) { @@ -23,6 +24,7 @@ internal static class AuthEndpoints auth.MapPost("/verify-email/resend", ResendVerificationAsync); auth.MapPost("/login", LoginAsync); auth.MapPost("/logout", LogoutAsync).RequireAuthorization(); + auth.MapDelete("/account", DeleteAccountAsync).RequireAuthorization(); auth.MapGet("/me", MeAsync).RequireAuthorization(); auth.MapGet("/providers", ProvidersAsync); auth.MapGet("/google", BeginGoogleAsync); @@ -152,15 +154,14 @@ internal static class AuthEndpoints private static async Task<IResult> CompleteGoogleAsync( SignInManager<ApplicationUser> signIn, - UserManager<ApplicationUser> users) + UserManager<ApplicationUser> users, + AuthCodeService codes, + RegistryEmailSender emailSender, + CancellationToken cancellationToken) { ExternalLoginInfo? info = await signIn.GetExternalLoginInfoAsync(); if (info is null) return Results.Redirect($"{LoginUrl}?error=oauth"); - SignInResult existing = await signIn.ExternalLoginSignInAsync( - info.LoginProvider, info.ProviderKey, isPersistent: true, bypassTwoFactor: false); - if (existing.Succeeded) return Results.Redirect(DashboardUrl); - if (!TryNormalizeEmail(info.Principal.FindFirstValue(ClaimTypes.Email), out string email)) { return Results.Redirect($"{LoginUrl}?error=missing_email"); @@ -168,20 +169,52 @@ internal static class AuthEndpoints ApplicationUser? user = await users.FindByEmailAsync(email); if (user is null) { - user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; + user = new ApplicationUser { UserName = email, Email = email }; IdentityResult created = await users.CreateAsync(user); if (!created.Succeeded) return Results.Redirect($"{LoginUrl}?error=account"); } - else if (!user.EmailConfirmed) + + ApplicationUser? loginOwner = await users.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + if (loginOwner is null) + { + IdentityResult loginAdded = await users.AddLoginAsync(user, info); + if (!loginAdded.Succeeded) return Results.Redirect($"{LoginUrl}?error=oauth"); + } + else if (loginOwner.Id != user.Id) { - user.EmailConfirmed = true; - await users.UpdateAsync(user); + return Results.Redirect($"{LoginUrl}?error=oauth"); } - IdentityResult loginAdded = await users.AddLoginAsync(user, info); - if (!loginAdded.Succeeded) return Results.Redirect($"{LoginUrl}?error=oauth"); - await signIn.SignInAsync(user, isPersistent: true); - return Results.Redirect(DashboardUrl); + string code = await codes.CreateAsync(user.Id, "signup", cancellationToken); + await emailSender.SendCodeAsync(email, code, "signup"); + string destination = $"{RegisterUrl}?verify=google" + + $"&email={Uri.EscapeDataString(email)}"; + return Results.Redirect(destination); + } + + private static async Task<IResult> DeleteAccountAsync( + [Microsoft.AspNetCore.Mvc.FromBody] DeleteAccountRequest request, + ClaimsPrincipal principal, + UserManager<ApplicationUser> users, + SignInManager<ApplicationUser> signIn) + { + ApplicationUser? user = await users.GetUserAsync(principal); + if (user?.Email is null) + { + return Results.Unauthorized(); + } + string confirmation = request.Confirmation?.Trim() ?? string.Empty; + if (!string.Equals(confirmation, user.Email, StringComparison.OrdinalIgnoreCase)) + { + return Results.BadRequest(new ApiError( + "confirmation_mismatch", + "Enter the account email address to confirm deletion.")); + } + + IdentityResult result = await users.DeleteAsync(user); + if (!result.Succeeded) return IdentityErrors(result); + await signIn.SignOutAsync(); + return Results.NoContent(); } private static async Task<IResult> StartRecoveryAsync( @@ -255,4 +288,5 @@ internal sealed record Credentials(string? Email, string? Password); internal sealed record EmailRequest(string? Email); internal sealed record VerificationRequest(string? Email, string? Code); internal sealed record RecoveryRequest(string? Email, string? Code, string? Password); +internal sealed record DeleteAccountRequest(string? Confirmation); internal sealed record ApiError(string Error, string Message); diff --git a/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.Designer.cs b/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.Designer.cs new file mode 100644 index 0000000..48bbc78 --- /dev/null +++ b/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.Designer.cs @@ -0,0 +1,383 @@ +// <auto-generated /> +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using XSharp.Web.Api.Data; + +#nullable disable + +namespace XSharp.Web.Api.Data.Migrations +{ + [DbContext(typeof(RegistryDbContext))] + [Migration("20260728182403_CascadeAccountOwnedRecords")] + partial class CascadeAccountOwnedRecords + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("registry") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<Guid>("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("ProviderKey") + .HasColumnType("text"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => + { + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<Guid>("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => + { + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<string>("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.ApplicationUser", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("text"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.AuthChallenge", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<byte[]>("CodeDigest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property<DateTimeOffset?>("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property<int>("FailedAttempts") + .HasColumnType("integer"); + + b.Property<string>("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Purpose", "ConsumedAt"); + + b.ToTable("AuthChallenges", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.RegistryToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<byte[]>("Digest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property<DateTimeOffset?>("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property<string>("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property<DateTimeOffset?>("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection<string[]>("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Digest") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("RegistryTokens", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.AuthChallenge", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.RegistryToken", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.cs b/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.cs new file mode 100644 index 0000000..4973944 --- /dev/null +++ b/backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.cs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace XSharp.Web.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class CascadeAccountOwnedRecords : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM registry."AuthChallenges" AS challenge + WHERE NOT EXISTS ( + SELECT 1 + FROM registry."AspNetUsers" AS account + WHERE account."Id" = challenge."UserId" + ); + + DELETE FROM registry."RegistryTokens" AS token + WHERE NOT EXISTS ( + SELECT 1 + FROM registry."AspNetUsers" AS account + WHERE account."Id" = token."UserId" + ); + """); + + migrationBuilder.AddForeignKey( + name: "FK_AuthChallenges_AspNetUsers_UserId", + schema: "registry", + table: "AuthChallenges", + column: "UserId", + principalSchema: "registry", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_RegistryTokens_AspNetUsers_UserId", + schema: "registry", + table: "RegistryTokens", + column: "UserId", + principalSchema: "registry", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AuthChallenges_AspNetUsers_UserId", + schema: "registry", + table: "AuthChallenges"); + + migrationBuilder.DropForeignKey( + name: "FK_RegistryTokens_AspNetUsers_UserId", + schema: "registry", + table: "RegistryTokens"); + } + } +} diff --git a/backend/Data/Migrations/RegistryDbContextModelSnapshot.cs b/backend/Data/Migrations/RegistryDbContextModelSnapshot.cs index 969ad4f..e7228ba 100644 --- a/backend/Data/Migrations/RegistryDbContextModelSnapshot.cs +++ b/backend/Data/Migrations/RegistryDbContextModelSnapshot.cs @@ -1,6 +1,4 @@ // <auto-generated /> -// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> -// SPDX-License-Identifier: AGPL-3.0-or-later using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -356,6 +354,24 @@ namespace XSharp.Web.Api.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("XSharp.Web.Api.Data.AuthChallenge", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.RegistryToken", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/backend/Data/RegistryDbContext.cs b/backend/Data/RegistryDbContext.cs index ccfee11..e30a923 100644 --- a/backend/Data/RegistryDbContext.cs +++ b/backend/Data/RegistryDbContext.cs @@ -29,6 +29,10 @@ internal sealed class RegistryDbContext(DbContextOptions<RegistryDbContext> opti entity.Property(challenge => challenge.Purpose).HasMaxLength(32); entity.Property(challenge => challenge.CodeDigest).HasMaxLength(32); entity.HasIndex(challenge => new { challenge.UserId, challenge.Purpose, challenge.ConsumedAt }); + entity.HasOne<ApplicationUser>() + .WithMany() + .HasForeignKey(challenge => challenge.UserId) + .OnDelete(DeleteBehavior.Cascade); }); builder.Entity<RegistryToken>(entity => { @@ -39,6 +43,10 @@ internal sealed class RegistryDbContext(DbContextOptions<RegistryDbContext> opti entity.Property(token => token.Scopes).HasColumnType("text[]"); entity.HasIndex(token => token.Digest).IsUnique(); entity.HasIndex(token => new { token.UserId, token.RevokedAt }); + entity.HasOne<ApplicationUser>() + .WithMany() + .HasForeignKey(token => token.UserId) + .OnDelete(DeleteBehavior.Cascade); }); } } diff --git a/frontend/public/scripts/auth-flow.v1.js b/frontend/public/scripts/auth-flow.v2.js index 695bcbd..4bab52f 100644 --- a/frontend/public/scripts/auth-flow.v1.js +++ b/frontend/public/scripts/auth-flow.v2.js @@ -77,6 +77,20 @@ async function initializeProviders() { } } +function initializeGoogleVerification() { + const parameters = new URLSearchParams(window.location.search); + const requestedEmail = parameters.get("email")?.trim().toLowerCase() ?? ""; + if (mode !== "register" || parameters.get("verify") !== "google" || !requestedEmail || !emailInput) { + return; + } + emailInput.value = requestedEmail; + if (!emailInput.checkValidity()) return; + email = requestedEmail; + showStep("code"); + showAlert("Google sign-in succeeded. Check your email for the X# verification code."); + window.history.replaceState({}, "", window.location.pathname); +} + form?.addEventListener("submit", async (event) => { event.preventDefault(); if (!mode) return; @@ -144,4 +158,5 @@ form?.addEventListener("submit", async (event) => { window.location.assign("https://repo.xsharp-lang.xyz/dashboard/"); }); +initializeGoogleVerification(); void initializeProviders(); diff --git a/frontend/src/scripts/dashboard.ts b/frontend/public/scripts/dashboard.v1.js index 84c700a..82286d5 100644 --- a/frontend/src/scripts/dashboard.ts +++ b/frontend/public/scripts/dashboard.v1.js @@ -1,34 +1,27 @@ // SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> // SPDX-License-Identifier: AGPL-3.0-or-later -export {}; +const root = document.querySelector("[data-dashboard]"); +const alert = root?.querySelector("[data-dashboard-alert]"); +const tokenList = root?.querySelector("[data-token-list]"); +const tokenCount = root?.querySelector("[data-token-count]"); +const dialog = document.querySelector("[data-token-dialog]"); +const tokenForm = dialog?.querySelector("[data-token-form]"); +const tokenSecret = dialog?.querySelector("[data-token-secret]"); +const tokenValue = dialog?.querySelector("[data-token-value]"); +const deleteDialog = document.querySelector("[data-delete-dialog]"); +const deleteForm = deleteDialog?.querySelector("[data-delete-form]"); +const deleteConfirmation = deleteDialog?.querySelector("[data-delete-confirmation]"); +let accountEmail = ""; -interface RegistryToken { - id: string; - name: string; - prefix: string; - scopes: string[]; - createdAt: string; - lastUsedAt: string | null; -} - -const root = document.querySelector<HTMLElement>("[data-dashboard]"); -const alert = root?.querySelector<HTMLElement>("[data-dashboard-alert]"); -const tokenList = root?.querySelector<HTMLElement>("[data-token-list]"); -const tokenCount = root?.querySelector<HTMLElement>("[data-token-count]"); -const dialog = document.querySelector<HTMLDialogElement>("[data-token-dialog]"); -const tokenForm = dialog?.querySelector<HTMLFormElement>("[data-token-form]"); -const tokenSecret = dialog?.querySelector<HTMLElement>("[data-token-secret]"); -const tokenValue = dialog?.querySelector<HTMLElement>("[data-token-value]"); - -function showAlert(message: string, error = false): void { +function showAlert(message, error = false) { if (!alert) return; alert.textContent = message; alert.dataset.kind = error ? "error" : "info"; alert.hidden = false; } -function renderTokens(tokens: RegistryToken[]): void { +function renderTokens(tokens) { if (!tokenList) return; if (tokenCount) tokenCount.textContent = String(tokens.length); if (tokens.length === 0) { @@ -55,44 +48,43 @@ function renderTokens(tokens: RegistryToken[]): void { ); } -async function loadTokens(): Promise<void> { +async function loadTokens() { const response = await fetch("/api/v1/tokens/", { credentials: "include" }); if (!response.ok) { showAlert("API tokens could not be loaded.", true); return; } - renderTokens((await response.json()) as RegistryToken[]); + renderTokens(await response.json()); } -async function revokeToken(id: string): Promise<void> { +async function revokeToken(id) { if (!window.confirm("Revoke this token? Existing CLI sessions using it will stop working.")) return; const response = await fetch(`/api/v1/tokens/${id}`, { method: "DELETE", credentials: "include" }); if (!response.ok) return showAlert("The token could not be revoked.", true); await loadTokens(); } -async function initialize(): Promise<void> { +async function initialize() { const response = await fetch("/api/v1/auth/me", { credentials: "include" }); if (response.status === 401) { window.location.replace("https://repo.xsharp-lang.xyz/login/"); return; } if (!response.ok) return showAlert("Account information could not be loaded.", true); - const account = (await response.json()) as { - email: string; - displayName: string; - emailVerified: boolean; - }; + const account = await response.json(); const email = account.email ?? ""; + accountEmail = email; const displayName = account.displayName || email.split("@")[0] || "Account"; - const avatar = root?.querySelector<HTMLElement>("[data-account-avatar]"); - const name = root?.querySelector<HTMLElement>("[data-account-name]"); - const emailElement = root?.querySelector<HTMLElement>("[data-account-email]"); - const status = root?.querySelector<HTMLElement>("[data-email-status]"); + const avatar = root?.querySelector("[data-account-avatar]"); + const name = root?.querySelector("[data-account-name]"); + const emailElement = root?.querySelector("[data-account-email]"); + const status = root?.querySelector("[data-email-status]"); + const deleteEmail = deleteDialog?.querySelector("[data-delete-email]"); if (avatar) avatar.textContent = displayName.slice(0, 1).toUpperCase(); if ( |