Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAlfaPC112026-07-28 21:38:04 +0300
committerAlfaPC112026-07-28 21:38:04 +0300
commit3e726a87c9ae60aea3403e12c8a0aab3870942df (patch)
tree09a55fb7328e8ca7eb29f43a5e930de557104c13
parent0c9ea445a277c23aeb7156578c623ac502591499 (diff)
downloadwebsite-3e726a87c9ae60aea3403e12c8a0aab3870942df.tar.gz
require verification after Google auth and add account deletion
-rw-r--r--README.md4
-rw-r--r--backend/Auth/AuthEndpoints.cs60
-rw-r--r--backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.Designer.cs383
-rw-r--r--backend/Data/Migrations/20260728182403_CascadeAccountOwnedRecords.cs68
-rw-r--r--backend/Data/Migrations/RegistryDbContextModelSnapshot.cs20
-rw-r--r--backend/Data/RegistryDbContext.cs8
-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.astro2
-rw-r--r--frontend/src/pages/repo/dashboard/index.astro36
-rw-r--r--frontend/src/styles/global.css21
11 files changed, 651 insertions, 55 deletions
diff --git a/README.md b/README.md
index 5979c6e..bc99a8c 100644
--- a/README.md
+++ b/README.md
@@ -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 (