Compare commits
12 Commits
6b77a96d52
...
37668dcfe0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37668dcfe0 | ||
|
|
40ea6e5e69 | ||
|
|
6ab0a3c5a1 | ||
|
|
8130f2053d | ||
|
|
9da908e4d2 | ||
|
|
d0dba73a29 | ||
|
|
9aa535ace8 | ||
|
|
f85b5a3634 | ||
|
|
39e1b39f7a | ||
|
|
283d3e34a0 | ||
|
|
2cd15509ba | ||
|
|
9d87f71bc1 |
@@ -28,13 +28,8 @@ CLICKHOUSE_PASSWORD=change_me_in_production
|
||||
SAAS_ADMIN_USER=admin
|
||||
SAAS_ADMIN_PASS=change_me_in_production
|
||||
|
||||
# SMTP (for email verification during registration)
|
||||
# Required for self-service sign-up. Without SMTP, only admin-created users can sign in.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM_EMAIL=noreply@cameleer.io
|
||||
# SMTP / email connector configuration is managed at runtime via the vendor
|
||||
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
|
||||
|
||||
# TLS (leave empty for self-signed)
|
||||
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||
|
||||
@@ -25,7 +25,7 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
|
||||
|---------|---------|-------------|
|
||||
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController` |
|
||||
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||
|
||||
@@ -42,9 +42,9 @@ Server containers join three networks: tenant network (primary), shared services
|
||||
|
||||
## Custom sign-in UI (`ui/sign-in/`)
|
||||
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration.
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration (registration is disabled by default until the vendor admin configures an email connector via the UI).
|
||||
|
||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors (SMTP) + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
|
||||
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
|
||||
@@ -83,12 +83,11 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
|
||||
5. Create admin user (SaaS admin with Logto console access)
|
||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||
8b. Configure SMTP email connector (if `SMTP_HOST`/`SMTP_USER` env vars set) — discovers factory via `/api/connector-factories`, creates connector with Cameleer-branded HTML email templates for Register/SignIn/ForgotPassword/Generic. Skips gracefully if SMTP not configured.
|
||||
8c. Enable self-service registration — sets `signInMode: "SignInAndRegister"`, `signUp: { identifiers: ["email"], password: true, verify: true }`, sign-in methods: email+password and username+password (backwards-compatible with admin user).
|
||||
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
|
||||
9. Cleanup seeded Logto apps
|
||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
||||
|
||||
SMTP env vars for email verification: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@cameleer.io`). Passed to `cameleer-logto` container via docker-compose. Both installers prompt for these in SaaS mode.
|
||||
SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
|
||||
|
||||
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
||||
|
||||
@@ -565,111 +565,16 @@ api_patch "/api/sign-in-exp" "{
|
||||
log "Sign-in branding configured."
|
||||
|
||||
# ============================================================
|
||||
# PHASE 8b: Configure SMTP email connector
|
||||
# PHASE 8c: Configure sign-in experience (sign-in only)
|
||||
# ============================================================
|
||||
# Required for email verification during registration and password reset.
|
||||
# Skipped if SMTP_HOST is not set (registration will not work without email delivery).
|
||||
# Registration is disabled by default. The vendor admin enables it
|
||||
# via the Email Connector UI after configuring SMTP delivery.
|
||||
|
||||
if [ -n "${SMTP_HOST:-}" ] && [ -n "${SMTP_USER:-}" ]; then
|
||||
log "Configuring SMTP email connector..."
|
||||
|
||||
# Discover available email connector factories
|
||||
FACTORIES=$(api_get "/api/connector-factories")
|
||||
# Prefer a factory with "smtp" in the ID
|
||||
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and (.id | test("smtp"; "i")))] | .[0].id // empty')
|
||||
if [ -z "$SMTP_FACTORY_ID" ]; then
|
||||
# Fall back to any non-demo Email factory
|
||||
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and .isDemo != true)] | .[0].id // empty')
|
||||
fi
|
||||
|
||||
if [ -n "$SMTP_FACTORY_ID" ]; then
|
||||
# Build SMTP config JSON
|
||||
SMTP_CONFIG=$(jq -n \
|
||||
--arg host "$SMTP_HOST" \
|
||||
--arg port "${SMTP_PORT:-587}" \
|
||||
--arg user "$SMTP_USER" \
|
||||
--arg pass "${SMTP_PASS:-}" \
|
||||
--arg from "${SMTP_FROM_EMAIL:-noreply@cameleer.io}" \
|
||||
'{
|
||||
host: $host,
|
||||
port: ($port | tonumber),
|
||||
auth: { user: $user, pass: $pass },
|
||||
fromEmail: $from,
|
||||
templates: [
|
||||
{
|
||||
usageType: "Register",
|
||||
contentType: "text/html",
|
||||
subject: "Verify your email for Cameleer",
|
||||
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
|
||||
},
|
||||
{
|
||||
usageType: "SignIn",
|
||||
contentType: "text/html",
|
||||
subject: "Your Cameleer sign-in code",
|
||||
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||
},
|
||||
{
|
||||
usageType: "ForgotPassword",
|
||||
contentType: "text/html",
|
||||
subject: "Reset your Cameleer password",
|
||||
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
|
||||
},
|
||||
{
|
||||
usageType: "Generic",
|
||||
contentType: "text/html",
|
||||
subject: "Your Cameleer verification code",
|
||||
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
# Check if an email connector already exists
|
||||
EXISTING_CONNECTORS=$(api_get "/api/connectors")
|
||||
EMAIL_CONNECTOR_ID=$(echo "$EXISTING_CONNECTORS" | jq -r '[.[] | select(.type == "Email")] | .[0].id // empty')
|
||||
|
||||
if [ -n "$EMAIL_CONNECTOR_ID" ]; then
|
||||
api_patch "/api/connectors/$EMAIL_CONNECTOR_ID" "{\"config\": $SMTP_CONFIG}" >/dev/null 2>&1
|
||||
log "Updated existing email connector: $EMAIL_CONNECTOR_ID"
|
||||
else
|
||||
CONNECTOR_RESPONSE=$(api_post "/api/connectors" "{\"connectorId\": \"$SMTP_FACTORY_ID\", \"config\": $SMTP_CONFIG}")
|
||||
CREATED_ID=$(echo "$CONNECTOR_RESPONSE" | jq -r '.id // empty')
|
||||
if [ -n "$CREATED_ID" ]; then
|
||||
log "Created SMTP email connector: $CREATED_ID (factory: $SMTP_FACTORY_ID)"
|
||||
else
|
||||
log "WARNING: Failed to create SMTP connector. Response: $(echo "$CONNECTOR_RESPONSE" | head -c 300)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "WARNING: No email connector factory found — email delivery will not work."
|
||||
log "Available factories: $(echo "$FACTORIES" | jq -c '[.[] | select(.type == "Email") | .id]')"
|
||||
fi
|
||||
else
|
||||
log "SMTP not configured (SMTP_HOST/SMTP_USER not set) — email delivery disabled."
|
||||
log "Set SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL env vars to enable."
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PHASE 8c: Enable registration (email + password)
|
||||
# ============================================================
|
||||
# Configures sign-in experience to allow self-service registration with email verification.
|
||||
# This runs AFTER the SMTP connector so email delivery is ready before registration opens.
|
||||
|
||||
log "Configuring sign-in experience for registration..."
|
||||
log "Configuring sign-in experience (sign-in only, no registration)..."
|
||||
api_patch "/api/sign-in-exp" '{
|
||||
"signInMode": "SignInAndRegister",
|
||||
"signUp": {
|
||||
"identifiers": ["email"],
|
||||
"password": true,
|
||||
"verify": true
|
||||
},
|
||||
"signInMode": "SignIn",
|
||||
"signIn": {
|
||||
"methods": [
|
||||
{
|
||||
"identifier": "email",
|
||||
"password": true,
|
||||
"verificationCode": false,
|
||||
"isPasswordPrimary": true
|
||||
},
|
||||
{
|
||||
"identifier": "username",
|
||||
"password": true,
|
||||
@@ -679,7 +584,7 @@ api_patch "/api/sign-in-exp" '{
|
||||
]
|
||||
}
|
||||
}' >/dev/null 2>&1
|
||||
log "Sign-in experience configured: SignInAndRegister (email + password)."
|
||||
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
|
||||
|
||||
# ============================================================
|
||||
# PHASE 9: Cleanup seeded apps
|
||||
|
||||
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
147
docs/superpowers/specs/2026-04-25-email-connector-ui-design.md
Normal file
147
docs/superpowers/specs/2026-04-25-email-connector-ui-design.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Email Connector UI Configuration
|
||||
|
||||
Move email connector setup from the one-shot installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration.
|
||||
|
||||
## Context
|
||||
|
||||
The current flow bakes SMTP configuration into the installer prompts and the Logto bootstrap script. This has two problems: (1) the bootstrap factory selection regex doesn't match the actual Logto SMTP factory ID (`simple-mail-transfer-protocol`), causing it to pick the wrong factory and fail silently; (2) bootstrap is a one-shot — if SMTP is added or changed after first boot, the connector is never created or updated.
|
||||
|
||||
Moving configuration to the UI fixes both issues and gives admins the ability to configure, test, change, or remove email delivery at any time.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **SMTP only for now**, but the architecture supports adding other providers (SES, SendGrid, Mailgun, etc.) with one form component and one service method per provider.
|
||||
- **Registration is disabled by default** until email is configured. Admins get a toggle to enable/disable registration independently once email works.
|
||||
- **Test email sends a real email** to a recipient address the admin provides, proving end-to-end delivery.
|
||||
- **Email templates are hardcoded** — four Cameleer-branded HTML templates (Register, SignIn, ForgotPassword, Generic) attached automatically when saving config.
|
||||
- **Email config lives under an expandable "Identity" sidebar section**, replacing the flat external Logto link. The section contains "Email Connector" and "Logto Console" (external link).
|
||||
|
||||
## Section 1: Removal
|
||||
|
||||
### Installer — bash (`installer/install.sh`)
|
||||
|
||||
- Remove SMTP prompt block (~lines 499-509): `prompt_yesno`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
|
||||
- Remove SMTP vars from `.env` generation
|
||||
- Remove SMTP vars from `cameleer.conf` persistence
|
||||
|
||||
### Installer — PowerShell (`installer/install.ps1`)
|
||||
|
||||
- Remove env var reads (lines 95-99): `$_ENV_SMTP_HOST` through `$_ENV_SMTP_FROM_EMAIL`
|
||||
- Remove config file parsing (lines 305-309): `smtp_host` through `smtp_from_email` cases
|
||||
- Remove env fallback merging (lines 342-346): `if (-not $c.SmtpHost)` blocks
|
||||
- Remove SMTP prompt block (lines 516-523)
|
||||
- Remove SMTP `.env` output (lines 778-782, 789)
|
||||
- Remove SMTP `cameleer.conf` output (lines 1028-1031, 1036)
|
||||
|
||||
### Compose template (`installer/templates/docker-compose.saas.yml`)
|
||||
|
||||
- Remove the 5 SMTP env vars from the cameleer-logto service (lines 30-35): `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
|
||||
|
||||
### Bootstrap (`docker/logto-bootstrap.sh`)
|
||||
|
||||
- Remove Phase 8b entirely (lines 568-649): SMTP connector creation via `/api/connector-factories` and `/api/connectors`
|
||||
- Modify Phase 8c (lines 657-682): Change `signInMode` from `"SignInAndRegister"` to `"SignIn"`. Remove `signUp.identifiers: ["email"]` and `signUp.verify: true`. Keep username+password sign-in method for the admin user. Registration gets enabled by the backend when the admin configures email.
|
||||
|
||||
## Section 2: Backend — Email Connector API
|
||||
|
||||
### New controller: `EmailConnectorController`
|
||||
|
||||
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java`
|
||||
|
||||
`@RestController`, `@RequestMapping("/api/vendor/email-connector")`, `@PreAuthorize("hasAuthority('SCOPE_platform:admin')")`
|
||||
|
||||
Endpoints:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/vendor/email-connector` | Returns current email connector config (password masked) + registration enabled state. 404 if none configured. |
|
||||
| POST | `/api/vendor/email-connector` | Creates or updates connector. Accepts SMTP config + optional `registrationEnabled` boolean. Attaches branded templates. Enables registration on first save unless explicitly set to false. |
|
||||
| DELETE | `/api/vendor/email-connector` | Removes connector, force-disables registration. |
|
||||
| POST | `/api/vendor/email-connector/test` | Accepts `{to: "email"}`, sends test email through configured connector, returns success/failure with message. |
|
||||
|
||||
### New service: `EmailConnectorService`
|
||||
|
||||
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||
|
||||
Responsibilities:
|
||||
- Maps provider-specific DTOs to Logto connector config format
|
||||
- Selects the correct Logto factory ID per provider (SMTP = `simple-mail-transfer-protocol`)
|
||||
- Hardcodes the four Cameleer-branded HTML email templates (Register, SignIn, ForgotPassword, Generic) with `{{code}}` placeholder and `#C6820E` brand color
|
||||
- Manages sign-in experience toggle via `PATCH /api/sign-in-exp`
|
||||
- Handles test email flow
|
||||
|
||||
### New methods on `LogtoManagementClient`
|
||||
|
||||
Location: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
|
||||
|
||||
Following the existing SSO connector method patterns:
|
||||
|
||||
- `listConnectorFactories()` — `GET /api/connector-factories`
|
||||
- `listConnectors()` — `GET /api/connectors`
|
||||
- `createConnector(factoryId, config)` — `POST /api/connectors`
|
||||
- `updateConnector(connectorId, config)` — `PATCH /api/connectors/{id}`
|
||||
- `deleteConnector(connectorId)` — `DELETE /api/connectors/{id}`
|
||||
- `testConnector(factoryId, email, config)` — `POST /api/connectors/{factoryId}/test` (Logto's built-in test endpoint; sends a real email with the provided config without needing to save first)
|
||||
- `updateSignInExperience(config)` — `PATCH /api/sign-in-exp`
|
||||
- `getSignInExperience()` — `GET /api/sign-in-exp`
|
||||
|
||||
## Section 3: Frontend — Email Configuration Page
|
||||
|
||||
### New page: `EmailConfigPage.tsx`
|
||||
|
||||
Location: `ui/src/pages/vendor/EmailConfigPage.tsx`
|
||||
|
||||
Follows the CertificatesPage pattern (Card layout, form fields, mutation hooks, toast notifications).
|
||||
|
||||
**Three UI states:**
|
||||
|
||||
| Email configured | Registration toggle | signInMode |
|
||||
|---|---|---|
|
||||
| No | Disabled, off | `SignIn` |
|
||||
| Yes | On (default after first save) | `SignInAndRegister` |
|
||||
| Yes | Off (admin chose to disable) | `SignIn` |
|
||||
|
||||
**Unconfigured state:**
|
||||
- Info alert: "Email delivery is not configured. Self-service registration is disabled."
|
||||
- SMTP form: Host (text), Port (number, default 587), Username (text), Password (password), From Email (email). All required.
|
||||
- Save button.
|
||||
|
||||
**Configured state:**
|
||||
- Card showing current config: host, port, username, from-email. Password masked as `••••••••`.
|
||||
- Registration toggle (switch) with label "Enable self-service registration".
|
||||
- Edit button to modify config, Delete button with confirmation dialog warning that removal disables registration.
|
||||
- "Send Test Email" section: text input for recipient + Send button. Success/failure shown inline.
|
||||
|
||||
### New hooks: `email-connector-hooks.ts`
|
||||
|
||||
Location: `ui/src/api/email-connector-hooks.ts`
|
||||
|
||||
Following the certificate-hooks pattern:
|
||||
|
||||
- `useEmailConnector()` — `GET /api/vendor/email-connector`
|
||||
- `useSaveEmailConnector()` — `POST /api/vendor/email-connector`
|
||||
- `useDeleteEmailConnector()` — `DELETE /api/vendor/email-connector`
|
||||
- `useTestEmailConnector()` — `POST /api/vendor/email-connector/test`
|
||||
|
||||
### Router (`router.tsx`)
|
||||
|
||||
- Add `/vendor/email` route inside the vendor `RequireScope` guard
|
||||
|
||||
### Sidebar (`Layout.tsx`)
|
||||
|
||||
- Replace the flat "Identity (Logto)" external link with an expandable "Identity" section
|
||||
- Items: "Email Connector" (internal link to `/vendor/email`), "Logto Console" (external link, preserved)
|
||||
|
||||
## Section 4: Extensibility — Adding Future Providers
|
||||
|
||||
To add a new email provider (e.g. AWS SES):
|
||||
|
||||
1. **Backend**: Add a request DTO and a mapping method in `EmailConnectorService` that maps to the provider's Logto config schema and returns the correct factory ID
|
||||
2. **Frontend**: Add a `SesConfigForm.tsx` component and a new option in the provider selector dropdown on `EmailConfigPage`
|
||||
|
||||
No changes needed to:
|
||||
- `EmailConnectorController` (provider-agnostic endpoints)
|
||||
- `LogtoManagementClient` (works with any factory/connector)
|
||||
- Email templates (shared across providers)
|
||||
- Registration toggle logic (shared across providers)
|
||||
- React Query hooks (provider-agnostic)
|
||||
Submodule installer updated: 4380aa790d...1ef0016965
@@ -398,6 +398,122 @@ public class LogtoManagementClient {
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
// --- Email Connector Management ---
|
||||
|
||||
/** List all connector factories available in Logto. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listConnectorFactories() {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connector-factories")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list connector factories: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** List all connectors. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listConnectors() {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list connectors: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a connector from a factory. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createConnector(String factoryId, Map<String, Object> connectorConfig) {
|
||||
if (!isAvailable()) return null;
|
||||
var body = new java.util.HashMap<String, Object>();
|
||||
body.put("connectorId", factoryId);
|
||||
body.put("config", connectorConfig);
|
||||
return (Map<String, Object>) restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Update an existing connector's config. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> connectorConfig) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("config", connectorConfig))
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Delete a connector. */
|
||||
public void deleteConnector(String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */
|
||||
public void testConnector(String factoryId, String email, Map<String, Object> connectorConfig) {
|
||||
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||
restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("email", email, "config", connectorConfig))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Get the current sign-in experience config. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getSignInExperience() {
|
||||
if (!isAvailable()) return null;
|
||||
try {
|
||||
return (Map<String, Object>) restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get sign-in experience: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the sign-in experience config (partial update). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> updateSignInExperience(Map<String, Object> updates) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(updates)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Update a user's password. */
|
||||
public void updateUserPassword(String userId, String newPassword) {
|
||||
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||
|
||||
114
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
114
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
package net.siegeln.cameleer.saas.vendor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vendor/email-connector")
|
||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||
public class EmailConnectorController {
|
||||
|
||||
private final EmailConnectorService emailConnectorService;
|
||||
|
||||
public EmailConnectorController(EmailConnectorService emailConnectorService) {
|
||||
this.emailConnectorService = emailConnectorService;
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
public record SmtpConfigRequest(
|
||||
@NotBlank String host,
|
||||
@Min(1) @Max(65535) int port,
|
||||
@NotBlank String username,
|
||||
@NotBlank String password,
|
||||
@NotBlank @Email String fromEmail,
|
||||
Boolean registrationEnabled
|
||||
) {}
|
||||
|
||||
public record TestEmailRequest(
|
||||
@NotBlank @Email String to
|
||||
) {}
|
||||
|
||||
public record EmailConnectorResponse(
|
||||
String connectorId,
|
||||
String factoryId,
|
||||
String host,
|
||||
int port,
|
||||
String username,
|
||||
String fromEmail,
|
||||
boolean registrationEnabled
|
||||
) {
|
||||
static EmailConnectorResponse from(EmailConnectorService.EmailConnectorStatus status) {
|
||||
return new EmailConnectorResponse(
|
||||
status.connectorId(),
|
||||
status.factoryId(),
|
||||
status.host(),
|
||||
status.port(),
|
||||
status.username(),
|
||||
status.fromEmail(),
|
||||
status.registrationEnabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Endpoints ---
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<EmailConnectorResponse> get() {
|
||||
var status = emailConnectorService.getEmailConnector();
|
||||
if (status == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(EmailConnectorResponse.from(status));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<EmailConnectorResponse> save(@Valid @RequestBody SmtpConfigRequest request) {
|
||||
var smtp = new EmailConnectorService.SmtpConfig(
|
||||
request.host(), request.port(), request.username(),
|
||||
request.password(), request.fromEmail()
|
||||
);
|
||||
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled());
|
||||
return ResponseEntity.ok(EmailConnectorResponse.from(status));
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ResponseEntity<Void> delete() {
|
||||
emailConnectorService.deleteEmailConnector();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, String>> test(@Valid @RequestBody TestEmailRequest request) {
|
||||
try {
|
||||
emailConnectorService.sendTestEmail(request.to());
|
||||
return ResponseEntity.ok(Map.of("status", "sent", "message", "Test email sent to " + request.to()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("status", "failed", "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/registration")
|
||||
public ResponseEntity<Void> toggleRegistration(@RequestBody Map<String, Boolean> body) {
|
||||
boolean enabled = body.getOrDefault("enabled", false);
|
||||
var existing = emailConnectorService.getEmailConnector();
|
||||
if (existing == null && enabled) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
emailConnectorService.setRegistrationEnabled(enabled);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
186
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
186
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
package net.siegeln.cameleer.saas.vendor;
|
||||
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class EmailConnectorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailConnectorService.class);
|
||||
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
|
||||
public EmailConnectorService(LogtoManagementClient logtoClient) {
|
||||
this.logtoClient = logtoClient;
|
||||
}
|
||||
|
||||
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
|
||||
|
||||
public record EmailConnectorStatus(
|
||||
String connectorId,
|
||||
String factoryId,
|
||||
String host,
|
||||
int port,
|
||||
String username,
|
||||
String fromEmail,
|
||||
boolean registrationEnabled
|
||||
) {}
|
||||
|
||||
/** Get the current email connector config, or null if none is configured. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public EmailConnectorStatus getEmailConnector() {
|
||||
var connectors = logtoClient.listConnectors();
|
||||
var emailConnector = connectors.stream()
|
||||
.filter(c -> "Email".equals(c.get("type")))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (emailConnector == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
|
||||
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
|
||||
String host = String.valueOf(config.getOrDefault("host", ""));
|
||||
int port = config.containsKey("port") ? ((Number) config.get("port")).intValue() : 587;
|
||||
String username = String.valueOf(auth.getOrDefault("user", ""));
|
||||
String fromEmail = String.valueOf(config.getOrDefault("fromEmail", ""));
|
||||
|
||||
boolean registrationEnabled = isRegistrationEnabled();
|
||||
|
||||
return new EmailConnectorStatus(
|
||||
String.valueOf(emailConnector.get("id")),
|
||||
String.valueOf(emailConnector.get("connectorId")),
|
||||
host, port, username, fromEmail, registrationEnabled
|
||||
);
|
||||
}
|
||||
|
||||
/** Create or update the SMTP email connector. Returns the connector status. */
|
||||
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) {
|
||||
var connectorConfig = buildSmtpConfig(smtp);
|
||||
|
||||
// Check if an email connector already exists
|
||||
var existing = getEmailConnector();
|
||||
if (existing != null) {
|
||||
logtoClient.updateConnector(existing.connectorId(), connectorConfig);
|
||||
log.info("Updated SMTP email connector: {}", existing.connectorId());
|
||||
} else {
|
||||
var result = logtoClient.createConnector(SMTP_FACTORY_ID, connectorConfig);
|
||||
log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown");
|
||||
}
|
||||
|
||||
// Handle registration toggle
|
||||
boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null);
|
||||
setRegistrationEnabled(enableReg);
|
||||
|
||||
return getEmailConnector();
|
||||
}
|
||||
|
||||
/** Delete the email connector and disable registration. */
|
||||
public void deleteEmailConnector() {
|
||||
var existing = getEmailConnector();
|
||||
if (existing != null) {
|
||||
logtoClient.deleteConnector(existing.connectorId());
|
||||
setRegistrationEnabled(false);
|
||||
log.info("Deleted email connector: {}", existing.connectorId());
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a test email through the configured connector. */
|
||||
public void sendTestEmail(String toEmail) {
|
||||
var existing = getEmailConnector();
|
||||
if (existing == null) {
|
||||
throw new IllegalStateException("No email connector configured");
|
||||
}
|
||||
// Re-read the full config from Logto to pass to the test endpoint
|
||||
var connectors = logtoClient.listConnectors();
|
||||
@SuppressWarnings("unchecked")
|
||||
var emailConnector = connectors.stream()
|
||||
.filter(c -> "Email".equals(c.get("type")))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Email connector not found"));
|
||||
@SuppressWarnings("unchecked")
|
||||
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
|
||||
logtoClient.testConnector(existing.factoryId(), toEmail, config);
|
||||
}
|
||||
|
||||
/** Set registration mode on the Logto sign-in experience. */
|
||||
public void setRegistrationEnabled(boolean enabled) {
|
||||
if (enabled) {
|
||||
logtoClient.updateSignInExperience(Map.of(
|
||||
"signInMode", "SignInAndRegister",
|
||||
"signUp", Map.of(
|
||||
"identifiers", List.of("email"),
|
||||
"password", true,
|
||||
"verify", true
|
||||
),
|
||||
"signIn", Map.of(
|
||||
"methods", List.of(
|
||||
Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
|
||||
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
|
||||
)
|
||||
)
|
||||
));
|
||||
} else {
|
||||
logtoClient.updateSignInExperience(Map.of(
|
||||
"signInMode", "SignIn",
|
||||
"signIn", Map.of(
|
||||
"methods", List.of(
|
||||
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
|
||||
)
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if registration is currently enabled in Logto. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private boolean isRegistrationEnabled() {
|
||||
var signInExp = logtoClient.getSignInExperience();
|
||||
if (signInExp == null) return false;
|
||||
return "SignInAndRegister".equals(signInExp.get("signInMode"));
|
||||
}
|
||||
|
||||
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||
var config = new HashMap<String, Object>();
|
||||
config.put("host", smtp.host());
|
||||
config.put("port", smtp.port());
|
||||
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
|
||||
config.put("fromEmail", smtp.fromEmail());
|
||||
config.put("templates", List.of(
|
||||
Map.of(
|
||||
"usageType", "Register",
|
||||
"contentType", "text/html",
|
||||
"subject", "Verify your email for Cameleer",
|
||||
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "SignIn",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer sign-in code",
|
||||
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "ForgotPassword",
|
||||
"contentType", "text/html",
|
||||
"subject", "Reset your Cameleer password",
|
||||
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "Generic",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer verification code",
|
||||
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||
)
|
||||
));
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
|
||||
- `main.tsx` — React 19 root
|
||||
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards, `LandingRedirect` that waits for scopes (redirects to `/onboarding` if user has zero orgs), `/register` route for OIDC sign-up flow, `/onboarding` route for self-service tenant creation
|
||||
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
||||
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Metrics, Infrastructure, Email Connector, Logto Console), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
||||
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
||||
- `config.ts` — fetch Logto config from /platform/api/config
|
||||
|
||||
@@ -22,12 +22,12 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
## Pages
|
||||
|
||||
- **Onboarding**: `OnboardingPage.tsx` — self-service trial tenant creation (org name + slug), shown to users with zero org memberships after sign-up
|
||||
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
|
||||
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`, `EmailConfigPage.tsx` (SMTP connector config, registration toggle, test email)
|
||||
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
||||
|
||||
## Custom Sign-in UI (`ui/sign-in/`)
|
||||
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
||||
|
||||
- `SignInPage.tsx` — sign-in + registration form with @cameleer/design-system components. Three modes: `signIn` (email/username + password), `register` (email + password + confirm), `verifyCode` (6-digit email verification). Reads `first_screen=register` from URL query params to determine initial view.
|
||||
- `SignInPage.tsx` — sign-in + registration form with @cameleer/design-system components. Three modes: `signIn` (email/username + password), `register` (email + password + confirm), `verifyCode` (6-digit email verification). Reads `first_screen=register` from URL query params to determine initial view. Registration is disabled by default — the vendor admin enables it via the Email Connector page after configuring SMTP.
|
||||
- `experience-api.ts` — Logto Experience API client. Sign-in: init -> verify password -> identify -> submit. Registration: init Register -> send verification code -> verify code -> add password profile -> identify -> submit. Auto-detects email vs username identifiers.
|
||||
|
||||
@@ -15,7 +15,7 @@ FROM ghcr.io/logto-io/logto:latest
|
||||
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
||||
RUN apk add --no-cache curl jq postgresql16-client
|
||||
|
||||
# Install all official Logto connectors (ensures SMTP email is available for self-hosted)
|
||||
# Install all official Logto connectors (email, SMS, social — configured at runtime via vendor UI)
|
||||
RUN cd /etc/logto/packages/core && npm run cli connector add -- --official 2>/dev/null || true
|
||||
|
||||
# Custom sign-in UI
|
||||
|
||||
70
ui/src/api/email-connector-hooks.ts
Normal file
70
ui/src/api/email-connector-hooks.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from './client';
|
||||
|
||||
export interface EmailConnectorResponse {
|
||||
connectorId: string;
|
||||
factoryId: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
fromEmail: string;
|
||||
registrationEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SmtpConfigRequest {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
fromEmail: string;
|
||||
registrationEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TestEmailResult {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useEmailConnector() {
|
||||
return useQuery<EmailConnectorResponse | null>({
|
||||
queryKey: ['vendor', 'email-connector'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<EmailConnectorResponse>('/vendor/email-connector');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('404')) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveEmailConnector() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<EmailConnectorResponse, Error, SmtpConfigRequest>({
|
||||
mutationFn: (config) => api.post('/vendor/email-connector', config),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEmailConnector() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: () => api.delete('/vendor/email-connector'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestEmailConnector() {
|
||||
return useMutation<TestEmailResult, Error, string>({
|
||||
mutationFn: (to) => api.post('/vendor/email-connector/test', { to }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleRegistration() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, boolean>({
|
||||
mutationFn: (enabled) => api.post('/vendor/email-connector/registration', { enabled }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Sidebar,
|
||||
TopBar,
|
||||
} from '@cameleer/design-system';
|
||||
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText } from 'lucide-react';
|
||||
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useScopes } from '../auth/useScopes';
|
||||
@@ -125,11 +125,20 @@ export function Layout() {
|
||||
>
|
||||
Infrastructure
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
||||
fontWeight: isActive(location, '/vendor/email') ? 600 : 400,
|
||||
color: isActive(location, '/vendor/email') ? 'var(--amber)' : 'var(--text-muted)' }}
|
||||
onClick={() => navigate('/vendor/email')}
|
||||
>
|
||||
<Mail size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
||||
Email Connector
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
||||
>
|
||||
Identity (Logto)
|
||||
Logto Console
|
||||
</div>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal file
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
FormField,
|
||||
Input,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import { Send, Trash2, Save, Power } from 'lucide-react';
|
||||
import {
|
||||
useEmailConnector,
|
||||
useSaveEmailConnector,
|
||||
useDeleteEmailConnector,
|
||||
useTestEmailConnector,
|
||||
useToggleRegistration,
|
||||
} from '../../api/email-connector-hooks';
|
||||
import styles from '../../styles/platform.module.css';
|
||||
|
||||
export function EmailConfigPage() {
|
||||
const { toast } = useToast();
|
||||
const { data: connector, isLoading, isError } = useEmailConnector();
|
||||
const saveMutation = useSaveEmailConnector();
|
||||
const deleteMutation = useDeleteEmailConnector();
|
||||
const testMutation = useTestEmailConnector();
|
||||
const toggleMutation = useToggleRegistration();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('587');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [fromEmail, setFromEmail] = useState('');
|
||||
const [testTo, setTestTo] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const isConfigured = connector != null;
|
||||
const showForm = !isConfigured || editing;
|
||||
|
||||
function startEditing() {
|
||||
if (connector) {
|
||||
setHost(connector.host);
|
||||
setPort(String(connector.port));
|
||||
setUsername(connector.username);
|
||||
setPassword('');
|
||||
setFromEmail(connector.fromEmail);
|
||||
}
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !username || !password || !fromEmail) {
|
||||
toast({ title: 'All fields are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMutation.mutateAsync({
|
||||
host,
|
||||
port: parseInt(port, 10) || 587,
|
||||
username,
|
||||
password,
|
||||
fromEmail,
|
||||
});
|
||||
toast({ title: 'Email connector saved', variant: 'success' });
|
||||
setEditing(false);
|
||||
setPassword('');
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to save', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
toast({ title: 'Email connector removed', variant: 'success' });
|
||||
setConfirmDelete(false);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to delete', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!testTo) {
|
||||
toast({ title: 'Enter a recipient email address', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await testMutation.mutateAsync(testTo);
|
||||
if (result.status === 'sent') {
|
||||
toast({ title: 'Test email sent', description: result.message, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Test failed', description: result.message, variant: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ title: 'Test failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRegistration() {
|
||||
if (!connector) return;
|
||||
const newValue = !connector.registrationEnabled;
|
||||
try {
|
||||
await toggleMutation.mutateAsync(newValue);
|
||||
toast({
|
||||
title: newValue ? 'Registration enabled' : 'Registration disabled',
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Alert variant="error" title="Failed to load email configuration">
|
||||
Could not fetch email connector data. Please refresh.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<h1 className={styles.heading}>Email Connector</h1>
|
||||
|
||||
{!isConfigured && (
|
||||
<Alert variant="info" title="Email delivery not configured">
|
||||
Self-service registration is disabled. Configure an SMTP connector to enable email
|
||||
verification for sign-up and password reset.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: 16 }}>
|
||||
{/* Current config card (when configured and not editing) */}
|
||||
{isConfigured && !editing && (
|
||||
<Card title="SMTP Configuration">
|
||||
<div className={styles.dividerList}>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Host</span>
|
||||
<span className={styles.kvValue}>{connector.host}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Port</span>
|
||||
<span className={styles.kvValue}>{connector.port}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Username</span>
|
||||
<span className={styles.kvValue}>{connector.username}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Password</span>
|
||||
<span className={styles.kvValueMono}>{'*'.repeat(8)}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>From Email</span>
|
||||
<span className={styles.kvValue}>{connector.fromEmail}</span>
|
||||
</div>
|
||||
<div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
|
||||
<Button variant="secondary" onClick={startEditing}>
|
||||
Edit
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="secondary" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 size={14} style={{ marginRight: 6 }} />
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="primary" onClick={handleDelete} loading={deleteMutation.isPending}
|
||||
style={{ background: 'var(--error)' }}>
|
||||
Confirm removal
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SMTP form (when unconfigured or editing) */}
|
||||
{showForm && (
|
||||
<Card title={isConfigured ? 'Edit SMTP Configuration' : 'SMTP Configuration'}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FormField label="SMTP Host *">
|
||||
<Input
|
||||
placeholder="smtp.example.com"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="SMTP Port *">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="587"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Username *">
|
||||
<Input
|
||||
placeholder="user@example.com"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password *">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={isConfigured ? 'Enter new password' : 'Password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="From Email *">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
value={fromEmail}
|
||||
onChange={(e) => setFromEmail(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="primary" onClick={handleSave} loading={saveMutation.isPending}>
|
||||
<Save size={14} style={{ marginRight: 6 }} />
|
||||
{isConfigured ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button variant="secondary" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Registration toggle (when configured) */}
|
||||
{isConfigured && !editing && (
|
||||
<Card title="Self-Service Registration">
|
||||
<div className={styles.dividerList}>
|
||||
<p className={styles.description}>
|
||||
{connector.registrationEnabled
|
||||
? 'New users can register with their email address and verify via a code sent to their inbox.'
|
||||
: 'Registration is disabled. Only admin-invited users can sign in.'}
|
||||
</p>
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<Button
|
||||
variant={connector.registrationEnabled ? 'secondary' : 'primary'}
|
||||
onClick={handleToggleRegistration}
|
||||
loading={toggleMutation.isPending}
|
||||
>
|
||||
<Power size={14} style={{ marginRight: 6 }} />
|
||||
{connector.registrationEnabled ? 'Disable Registration' : 'Enable Registration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test email (when configured and not editing) */}
|
||||
{isConfigured && !editing && (
|
||||
<Card title="Send Test Email">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FormField label="Recipient">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button variant="primary" onClick={handleTest} loading={testMutation.isPending}>
|
||||
<Send size={14} style={{ marginRight: 6 }} />
|
||||
Send Test Email
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
|
||||
import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
||||
import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
|
||||
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
|
||||
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
|
||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||
import { SsoPage } from './pages/tenant/SsoPage';
|
||||
@@ -102,6 +103,11 @@ export function AppRouter() {
|
||||
<InfrastructurePage />
|
||||
</RequireScope>
|
||||
} />
|
||||
<Route path="/vendor/email" element={
|
||||
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||
<EmailConfigPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
|
||||
{/* Tenant portal */}
|
||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||
|
||||
Reference in New Issue
Block a user