diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..e41d7ef --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f000208..faf8edf 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -21,22 +21,12 @@ - 強制重設 superuser 密碼 - 只執行 migration(差異安裝),不重建 schema -## 建議實作方式(.NET) -- 提供一個 CLI 或安裝頁面(類 nopCommerce / Wagtail) -- CLI 支援互動式輸入(DB 連線、admin 帳號、密碼) -- 內部流程一致: - 1) 讀取/寫入連線資訊 - 2) 執行 migration(`dbContext.Database.Migrate()`) - 3) 建立角色與 superuser(若不存在) - -## CLI 指令規格(草案) +## CLI 指令規格(實作) ### 通用參數 - `--connection-string `: DB 連線字串 -- `--db-provider `: 預設 `postgres` -- `--appsettings `: 設定檔路徑(預設 `appsettings.json`) +- `--appsettings `: 設定檔路徑(讀/寫 `ConnectionStrings:Default`) - `--no-prompt`: 不使用互動輸入(CI/CD) -- `--dry-run`: 只檢查,不寫入 - `--verbose`: 詳細輸出 ### 1) `installer init` @@ -49,11 +39,13 @@ - `--force`: 若偵測已初始化,仍強制執行 流程: -1) 讀取/寫入 DB 連線資訊 +1) 解析連線字串(參數或 appsettings) + - 若提供 `--connection-string`,會寫入 appsettings + - 若 appsettings 中缺少連線字串,會互動式詢問並寫入 2) 執行 migrations(不 Drop) 3) 建立 roles(admin, support) -4) 建立 admin(不存在才建立) -5) 寫入安裝鎖定(install.lock 或 DB flag) +4) 建立 admin(不存在才建立)並加入 admin 角色 +5) 寫入安裝鎖定(DB flag: `system_flags` / `installed=true`) ### 2) `installer add-admin` 用途:新增 superuser @@ -64,7 +56,7 @@ - `--admin-display-name ` 流程: -1) 檢查 DB 連線 +1) 解析連線字串 2) 建立使用者並指派 admin 角色 ### 3) `installer reset-admin-password` @@ -75,9 +67,8 @@ - `--admin-password ` 流程: -1) 檢查帳號存在 +1) 解析連線字串 2) 更新密碼(強制) -3) 寫入 audit log ### 4) `installer migrate` 用途:只執行 migrations @@ -86,9 +77,8 @@ - `--target `: 指定遷移(可選) 流程: -1) 連線檢查 -2) 執行 migrations -3) 輸出版本 +1) 解析連線字串 +2) 執行 migrations(可指定 target) ## 安全注意 - 密碼必須符合強度規則 diff --git a/docs/SCHEMA.sql b/docs/SCHEMA.sql index 2ce5b7d..fa55a2c 100644 --- a/docs/SCHEMA.sql +++ b/docs/SCHEMA.sql @@ -1,4 +1,7 @@ --- PostgreSQL schema draft (MVP) +-- PostgreSQL schema draft (MVP) +-- Note: OpenIddict/Identity tables are based on EF Core migrations. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE tenants ( id UUID PRIMARY KEY, @@ -10,62 +13,137 @@ CREATE TABLE tenants ( CREATE TABLE users ( id UUID PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - email_verified_at TIMESTAMPTZ, - status TEXT NOT NULL DEFAULT 'active', + user_name TEXT, + normalized_user_name TEXT, + email TEXT, + normalized_email TEXT, + email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + password_hash TEXT, + security_stamp TEXT, + concurrency_stamp TEXT, + phone_number TEXT, + phone_number_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, + lockout_end TIMESTAMPTZ, + lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE, + access_failed_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE oauth_clients ( +CREATE TABLE roles ( id UUID PRIMARY KEY, - tenant_id UUID NOT NULL REFERENCES tenants(id), + name TEXT, + normalized_name TEXT, + concurrency_stamp TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE user_claims ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + claim_type TEXT, + claim_value TEXT +); + +CREATE TABLE role_claims ( + id SERIAL PRIMARY KEY, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + claim_type TEXT, + claim_value TEXT +); + +CREATE TABLE user_logins ( + login_provider TEXT NOT NULL, + provider_key TEXT NOT NULL, + provider_display_name TEXT, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (login_provider, provider_key) +); + +CREATE TABLE user_tokens ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + login_provider TEXT NOT NULL, name TEXT NOT NULL, - redirect_uris TEXT[] NOT NULL DEFAULT '{}', - client_type TEXT NOT NULL DEFAULT 'confidential', - client_secret_hash TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + value TEXT, + PRIMARY KEY (user_id, login_provider, name) ); -ALTER TABLE oauth_clients - ADD CONSTRAINT chk_oauth_clients_secret - CHECK ( - (client_type = 'confidential' AND client_secret_hash IS NOT NULL) - OR (client_type <> 'confidential') - ); +CREATE UNIQUE INDEX idx_users_normalized_user_name ON users(normalized_user_name); +CREATE UNIQUE INDEX idx_users_normalized_email ON users(normalized_email); +CREATE UNIQUE INDEX idx_roles_normalized_name ON roles(normalized_name); -CREATE TABLE oauth_codes ( - id UUID PRIMARY KEY, - client_id UUID NOT NULL REFERENCES oauth_clients(id), - user_id UUID NOT NULL REFERENCES users(id), - code_hash TEXT NOT NULL, - code_challenge TEXT NOT NULL, - code_challenge_method TEXT NOT NULL DEFAULT 'S256', - expires_at TIMESTAMPTZ NOT NULL, - consumed_at TIMESTAMPTZ +-- OpenIddict (EF Core default tables) +CREATE TABLE "OpenIddictApplications" ( + "Id" UUID PRIMARY KEY, + "ApplicationType" TEXT, + "ClientId" TEXT, + "ClientSecret" TEXT, + "ConsentType" TEXT, + "DisplayName" TEXT, + "DisplayNames" JSONB, + "Permissions" JSONB, + "PostLogoutRedirectUris" JSONB, + "Properties" JSONB, + "RedirectUris" JSONB, + "Requirements" JSONB, + "ConcurrencyToken" TEXT ); -CREATE TABLE oauth_tokens ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id), - client_id UUID NOT NULL REFERENCES oauth_clients(id), - access_token_hash TEXT NOT NULL, - refresh_token_hash TEXT NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ +CREATE TABLE "OpenIddictAuthorizations" ( + "Id" UUID PRIMARY KEY, + "ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL, + "CreationDate" TIMESTAMPTZ, + "Status" TEXT, + "Subject" TEXT, + "Type" TEXT, + "Scopes" JSONB, + "Properties" JSONB, + "ConcurrencyToken" TEXT ); -CREATE TABLE oidc_id_tokens ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id), - client_id UUID NOT NULL REFERENCES oauth_clients(id), - id_token_hash TEXT NOT NULL, - expires_at TIMESTAMPTZ NOT NULL +CREATE TABLE "OpenIddictScopes" ( + "Id" UUID PRIMARY KEY, + "Description" TEXT, + "Descriptions" JSONB, + "DisplayName" TEXT, + "DisplayNames" JSONB, + "Name" TEXT, + "Properties" JSONB, + "Resources" JSONB, + "ConcurrencyToken" TEXT ); +CREATE TABLE "OpenIddictTokens" ( + "Id" UUID PRIMARY KEY, + "ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL, + "AuthorizationId" UUID REFERENCES "OpenIddictAuthorizations"("Id") ON DELETE SET NULL, + "CreationDate" TIMESTAMPTZ, + "ExpirationDate" TIMESTAMPTZ, + "RedemptionDate" TIMESTAMPTZ, + "Payload" TEXT, + "ReferenceId" TEXT, + "Status" TEXT, + "Subject" TEXT, + "Type" TEXT, + "Properties" JSONB, + "ConcurrencyToken" TEXT +); + +CREATE UNIQUE INDEX idx_openiddict_applications_client_id ON "OpenIddictApplications"("ClientId"); +CREATE UNIQUE INDEX idx_openiddict_scopes_name ON "OpenIddictScopes"("Name"); +CREATE UNIQUE INDEX idx_openiddict_tokens_reference_id ON "OpenIddictTokens"("ReferenceId"); +CREATE INDEX idx_openiddict_tokens_subject ON "OpenIddictTokens"("Subject"); + CREATE TABLE newsletter_lists ( id UUID PRIMARY KEY, - tenant_id UUID NOT NULL REFERENCES tenants(id), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -73,9 +151,9 @@ CREATE TABLE newsletter_lists ( CREATE TABLE newsletter_subscriptions ( id UUID PRIMARY KEY, - list_id UUID NOT NULL REFERENCES newsletter_lists(id), + list_id UUID NOT NULL REFERENCES newsletter_lists(id) ON DELETE CASCADE, email TEXT NOT NULL, - user_id UUID REFERENCES users(id), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'pending', preferences JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -85,7 +163,7 @@ CREATE TABLE newsletter_subscriptions ( CREATE TABLE email_verifications ( id UUID PRIMARY KEY, email TEXT NOT NULL, - tenant_id UUID NOT NULL REFERENCES tenants(id), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, purpose TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, @@ -94,7 +172,7 @@ CREATE TABLE email_verifications ( CREATE TABLE unsubscribe_tokens ( id UUID PRIMARY KEY, - subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id), + subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ @@ -109,17 +187,11 @@ CREATE TABLE audit_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE roles ( +CREATE TABLE system_flags ( id UUID PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE user_roles ( - user_id UUID NOT NULL REFERENCES users(id), - role_id UUID NOT NULL REFERENCES roles(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, role_id) + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_newsletter_subscriptions_email @@ -128,17 +200,5 @@ CREATE INDEX idx_newsletter_subscriptions_email CREATE INDEX idx_newsletter_subscriptions_list_id ON newsletter_subscriptions(list_id); -CREATE INDEX idx_oauth_tokens_user_id - ON oauth_tokens(user_id); - -CREATE INDEX idx_oauth_tokens_client_id - ON oauth_tokens(client_id); - -CREATE INDEX idx_oauth_codes_client_id - ON oauth_codes(client_id); - CREATE INDEX idx_email_verifications_email ON email_verifications(email); - -CREATE INDEX idx_user_roles_role_id - ON user_roles(role_id); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index da17300..7e9d5e2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -13,6 +13,7 @@ paths: get: summary: OAuth2 Authorization Endpoint description: Authorization Code + PKCE flow + security: [] parameters: - in: query name: client_id @@ -49,20 +50,15 @@ paths: /oauth/token: post: summary: OAuth2 Token Endpoint + security: [] requestBody: required: true content: application/x-www-form-urlencoded: schema: - type: object - required: [grant_type, code, redirect_uri, client_id, code_verifier] - properties: - grant_type: { type: string, enum: [authorization_code, refresh_token] } - code: { type: string } - redirect_uri: { type: string } - client_id: { type: string } - code_verifier: { type: string } - refresh_token: { type: string } + oneOf: + - $ref: '#/components/schemas/AuthorizationCodeTokenRequest' + - $ref: '#/components/schemas/RefreshTokenRequest' responses: '200': description: Token response @@ -88,6 +84,7 @@ paths: /auth/register: post: summary: Register user + security: [] requestBody: required: true content: @@ -105,12 +102,13 @@ paths: /auth/login: post: summary: API login + security: [] requestBody: required: true content: - application/json: + application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/LoginRequest' + $ref: '#/components/schemas/PasswordTokenRequest' responses: '200': description: Token response @@ -122,12 +120,13 @@ paths: /auth/refresh: post: summary: Refresh token + security: [] requestBody: required: true content: - application/json: + application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/RefreshRequest' + $ref: '#/components/schemas/RefreshTokenRequest' responses: '200': description: Token response @@ -157,6 +156,7 @@ paths: /auth/password/forgot: post: summary: Request password reset + security: [] requestBody: required: true content: @@ -170,6 +170,7 @@ paths: /auth/password/reset: post: summary: Reset password + security: [] requestBody: required: true content: @@ -183,11 +184,16 @@ paths: /auth/email/verify: get: summary: Verify email + security: [] parameters: - in: query name: token required: true schema: { type: string } + - in: query + name: email + required: true + schema: { type: string, format: email } responses: '200': description: Email verified @@ -208,6 +214,7 @@ paths: /newsletter/subscribe: post: summary: Subscribe (unauthenticated allowed) + security: [] requestBody: required: true content: @@ -220,11 +227,12 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Subscription' + $ref: '#/components/schemas/PendingSubscriptionResponse' /newsletter/confirm: get: summary: Confirm subscription (double opt-in) + security: [] parameters: - in: query name: token @@ -241,6 +249,7 @@ paths: /newsletter/unsubscribe: post: summary: Unsubscribe single list + security: [] requestBody: required: true content: @@ -515,14 +524,25 @@ components: email: { type: string, format: email } password: { type: string } - LoginRequest: + PasswordTokenRequest: type: object - required: [email, password, client_id] + required: [grant_type, username, password] properties: - email: { type: string, format: email } + grant_type: { type: string, enum: [password] } + username: { type: string, format: email } password: { type: string } - client_id: { type: string } scope: { type: string } + client_id: { type: string } + + AuthorizationCodeTokenRequest: + type: object + required: [grant_type, code, redirect_uri, code_verifier] + properties: + grant_type: { type: string, enum: [authorization_code] } + code: { type: string } + redirect_uri: { type: string } + code_verifier: { type: string } + client_id: { type: string } RefreshRequest: type: object @@ -531,6 +551,14 @@ components: refresh_token: { type: string } client_id: { type: string } + RefreshTokenRequest: + type: object + required: [grant_type, refresh_token] + properties: + grant_type: { type: string, enum: [refresh_token] } + refresh_token: { type: string } + client_id: { type: string } + ForgotPasswordRequest: type: object required: [email] @@ -539,8 +567,9 @@ components: ResetPasswordRequest: type: object - required: [token, new_password] + required: [email, token, new_password] properties: + email: { type: string, format: email } token: { type: string } new_password: { type: string } @@ -571,6 +600,13 @@ components: preferences: { type: object } created_at: { type: string, format: date-time } + PendingSubscriptionResponse: + allOf: + - $ref: '#/components/schemas/Subscription' + - type: object + properties: + confirm_token: { type: string } + Tenant: type: object properties: diff --git a/src/MemberCenter.Installer/MemberCenter.Installer.csproj b/src/MemberCenter.Installer/MemberCenter.Installer.csproj index 3ada09f..1c8550e 100644 --- a/src/MemberCenter.Installer/MemberCenter.Installer.csproj +++ b/src/MemberCenter.Installer/MemberCenter.Installer.csproj @@ -6,11 +6,20 @@ - - Exe - net8.0 - enable - enable - - - + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 83fa4f4..8a6fbe7 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -1,2 +1,436 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Identity; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.CommandLine; + +var root = new RootCommand("Member Center installer"); + +var connectionStringOption = new Option( + name: "--connection-string", + description: "Database connection string"); +connectionStringOption.AddAlias("-c"); + +var appsettingsOption = new Option( + name: "--appsettings", + description: "Path to appsettings.json"); + +var noPromptOption = new Option( + name: "--no-prompt", + description: "Disable interactive prompts"); + +var verboseOption = new Option( + name: "--verbose", + description: "Enable verbose output"); + +var forceOption = new Option( + name: "--force", + description: "Force execution even if already installed"); + +var adminEmailOption = new Option( + name: "--admin-email", + description: "Admin email"); + +var adminPasswordOption = new Option( + name: "--admin-password", + description: "Admin password"); + +var adminDisplayNameOption = new Option( + name: "--admin-display-name", + description: "Admin display name (optional)"); + +var targetMigrationOption = new Option( + name: "--target", + description: "Target migration"); + +var initCommand = new Command("init", "Initialize database (migrate + seed + admin)"); +initCommand.AddOption(connectionStringOption); +initCommand.AddOption(appsettingsOption); +initCommand.AddOption(noPromptOption); +initCommand.AddOption(verboseOption); +initCommand.AddOption(forceOption); +initCommand.AddOption(adminEmailOption); +initCommand.AddOption(adminPasswordOption); +initCommand.AddOption(adminDisplayNameOption); + +initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) => +{ + var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); + if (string.IsNullOrWhiteSpace(resolvedConnection)) + { + Console.Error.WriteLine("Connection string is required."); + return; + } + + var services = BuildServices(resolvedConnection); + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + if (!force && await IsInstalledAsync(db)) + { + Console.Error.WriteLine("System already installed. Use --force to override."); + return; + } + + if (!noPrompt) + { + adminEmail ??= Prompt("Admin email", "admin@example.com"); + adminPassword ??= PromptSecret("Admin password"); + } + + adminEmail ??= "admin@example.com"; + if (string.IsNullOrWhiteSpace(adminPassword)) + { + Console.Error.WriteLine("Admin password is required."); + return; + } + + await db.Database.MigrateAsync(); + + await EnsureRoleAsync(roleManager, "admin"); + await EnsureRoleAsync(roleManager, "support"); + + var admin = await userManager.FindByEmailAsync(adminEmail); + if (admin is null) + { + admin = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = adminEmail, + Email = adminEmail, + EmailConfirmed = true + }; + var createResult = await userManager.CreateAsync(admin, adminPassword); + if (!createResult.Succeeded) + { + Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description))); + return; + } + } + + if (!await userManager.IsInRoleAsync(admin, "admin")) + { + await userManager.AddToRoleAsync(admin, "admin"); + } + + await SetInstalledFlagAsync(db); + + if (verbose) + { + Console.WriteLine("Init completed."); + } +}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption); + +var addAdminCommand = new Command("add-admin", "Add admin user"); +addAdminCommand.AddOption(connectionStringOption); +addAdminCommand.AddOption(appsettingsOption); +addAdminCommand.AddOption(noPromptOption); +addAdminCommand.AddOption(adminEmailOption); +addAdminCommand.AddOption(adminPasswordOption); +addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) => +{ + var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); + if (string.IsNullOrWhiteSpace(resolvedConnection)) + { + Console.Error.WriteLine("Connection string is required."); + return; + } + + if (!noPrompt) + { + adminEmail ??= Prompt("Admin email", "admin@example.com"); + adminPassword ??= PromptSecret("Admin password"); + } + + adminEmail ??= "admin@example.com"; + if (string.IsNullOrWhiteSpace(adminPassword)) + { + Console.Error.WriteLine("Admin password is required."); + return; + } + + var services = BuildServices(resolvedConnection); + await using var scope = services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + await EnsureRoleAsync(roleManager, "admin"); + + var admin = await userManager.FindByEmailAsync(adminEmail); + if (admin is null) + { + admin = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = adminEmail, + Email = adminEmail, + EmailConfirmed = true + }; + var createResult = await userManager.CreateAsync(admin, adminPassword); + if (!createResult.Succeeded) + { + Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description))); + return; + } + } + + if (!await userManager.IsInRoleAsync(admin, "admin")) + { + await userManager.AddToRoleAsync(admin, "admin"); + } + + Console.WriteLine("Admin user ready."); +}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption); + +var resetCommand = new Command("reset-admin-password", "Reset admin password"); +resetCommand.AddOption(connectionStringOption); +resetCommand.AddOption(appsettingsOption); +resetCommand.AddOption(noPromptOption); +resetCommand.AddOption(adminEmailOption); +resetCommand.AddOption(adminPasswordOption); +resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) => +{ + var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); + if (string.IsNullOrWhiteSpace(resolvedConnection)) + { + Console.Error.WriteLine("Connection string is required."); + return; + } + + if (!noPrompt) + { + adminEmail ??= Prompt("Admin email", "admin@example.com"); + adminPassword ??= PromptSecret("New password"); + } + + adminEmail ??= "admin@example.com"; + if (string.IsNullOrWhiteSpace(adminPassword)) + { + Console.Error.WriteLine("Admin password is required."); + return; + } + + var services = BuildServices(resolvedConnection); + await using var scope = services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var admin = await userManager.FindByEmailAsync(adminEmail); + if (admin is null) + { + Console.Error.WriteLine("Admin not found."); + return; + } + + var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin); + var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword); + if (!result.Succeeded) + { + Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); + return; + } + + Console.WriteLine("Password reset completed."); +}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption); + +var migrateCommand = new Command("migrate", "Run migrations only"); +migrateCommand.AddOption(connectionStringOption); +migrateCommand.AddOption(appsettingsOption); +migrateCommand.AddOption(targetMigrationOption); +migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) => +{ + var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true); + if (string.IsNullOrWhiteSpace(resolvedConnection)) + { + Console.Error.WriteLine("Connection string is required."); + return; + } + + var services = BuildServices(resolvedConnection); + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (string.IsNullOrWhiteSpace(target)) + { + await db.Database.MigrateAsync(); + } + else + { + var migrator = scope.ServiceProvider.GetRequiredService(); + await migrator.MigrateAsync(target); + } + + Console.WriteLine("Migrations completed."); +}, connectionStringOption, appsettingsOption, targetMigrationOption); + +root.AddCommand(initCommand); +root.AddCommand(addAdminCommand); +root.AddCommand(resetCommand); +root.AddCommand(migrateCommand); + +return await root.InvokeAsync(args); + +static IServiceProvider BuildServices(string connectionString) +{ + var services = new ServiceCollection(); + services.AddDbContext(options => + { + options.UseNpgsql(connectionString); + options.UseOpenIddict(); + }); + + services.AddIdentity(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 8; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + return services.BuildServiceProvider(); +} + +static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt) +{ + var targetPath = string.IsNullOrWhiteSpace(appsettingsPath) ? "appsettings.json" : appsettingsPath; + + if (!string.IsNullOrWhiteSpace(connectionString)) + { + WriteConnectionString(targetPath, connectionString); + return connectionString; + } + + if (File.Exists(targetPath)) + { + var config = new ConfigurationBuilder() + .AddJsonFile(targetPath) + .Build(); + var existing = config.GetConnectionString("Default"); + if (!string.IsNullOrWhiteSpace(existing)) + { + return existing; + } + } + + if (noPrompt) + { + return null; + } + + var host = Prompt("DB host", "localhost"); + var database = Prompt("DB name", "member_center"); + var username = Prompt("DB user", "postgres"); + var password = PromptSecret("DB password"); + var generated = $"Host={host};Database={database};Username={username};Password={password}"; + WriteConnectionString(targetPath, generated); + return generated; +} + +static void WriteConnectionString(string path, string connectionString) +{ + JsonNode root; + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + root = JsonNode.Parse(json) ?? new JsonObject(); + } + else + { + root = new JsonObject(); + } + + var obj = root as JsonObject ?? new JsonObject(); + var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject(); + connectionStrings["Default"] = connectionString; + obj["ConnectionStrings"] = connectionStrings; + + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(path, obj.ToJsonString(options)); +} + +static async Task IsInstalledAsync(MemberCenterDbContext db) +{ + var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed"); + return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase); +} + +static async Task SetInstalledFlagAsync(MemberCenterDbContext db) +{ + var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed"); + if (flag is null) + { + db.SystemFlags.Add(new SystemFlag + { + Id = Guid.NewGuid(), + Key = "installed", + Value = "true", + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else + { + flag.Value = "true"; + flag.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(); +} + +static async Task EnsureRoleAsync(RoleManager roleManager, string roleName) +{ + if (!await roleManager.RoleExistsAsync(roleName)) + { + var result = await roleManager.CreateAsync(new ApplicationRole + { + Id = Guid.NewGuid(), + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + + if (!result.Succeeded) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); + } + } +} + +static string Prompt(string label, string defaultValue) +{ + Console.Write($"{label} [{defaultValue}]: "); + var input = Console.ReadLine(); + return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim(); +} + +static string PromptSecret(string label) +{ + Console.Write($"{label}: "); + var password = string.Empty; + ConsoleKeyInfo key; + while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter) + { + if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password = password[..^1]; + Console.Write("\b \b"); + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + password += key.KeyChar; + Console.Write("*"); + } + } + Console.WriteLine(); + return password; +}