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;
+}