diff --git a/docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md b/docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md new file mode 100644 index 0000000..db84eeb --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md @@ -0,0 +1,1371 @@ +# Email Connector UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move email connector configuration from the installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration. + +**Architecture:** The SaaS backend gets a new `EmailConnectorService` + `EmailConnectorController` that manages Logto email connectors via the Management API. The vendor UI gets an Email Config page under an expandable "Identity" sidebar section. The installer, compose templates, and bootstrap script are stripped of all SMTP config — email is configured entirely at runtime. + +**Tech Stack:** Spring Boot (Java 21), React 19, @cameleer/design-system, @tanstack/react-query, Logto Management API + +--- + +### Task 1: Add email connector methods to LogtoManagementClient + +**Files:** +- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java:399` (after SSO connector section) + +- [ ] **Step 1: Add connector management methods** + +Add the following methods after line 399 (after `unlinkSsoConnectorFromOrg`), before the user management methods: + +```java +// --- Email Connector Management --- + +/** List all connector factories available in Logto. */ +@SuppressWarnings("unchecked") +public List> 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> 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 createConnector(String factoryId, Map connectorConfig) { + if (!isAvailable()) return null; + var body = new java.util.HashMap(); + body.put("connectorId", factoryId); + body.put("config", connectorConfig); + return (Map) 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 updateConnector(String connectorId, Map connectorConfig) { + if (!isAvailable()) return null; + return (Map) 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 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 getSignInExperience() { + if (!isAvailable()) return null; + try { + return (Map) 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 updateSignInExperience(Map updates) { + if (!isAvailable()) return null; + return (Map) restClient.patch() + .uri(config.getLogtoEndpoint() + "/api/sign-in-exp") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(updates) + .retrieve() + .body(Map.class); +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +git commit -m "feat: add email connector and sign-in experience methods to LogtoManagementClient" +``` + +--- + +### Task 2: Create EmailConnectorService + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` + +- [ ] **Step 1: Create the service** + +```java +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) emailConnector.getOrDefault("config", Map.of()); + var auth = (Map) 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) 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 buildSmtpConfig(SmtpConfig smtp) { + var config = new HashMap(); + 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", "
Cameleer

Enter this code to verify your email and create your account:

{{code}}

This code expires in 10 minutes. If you did not request this, you can safely ignore this email.

" + ), + Map.of( + "usageType", "SignIn", + "contentType", "text/html", + "subject", "Your Cameleer sign-in code", + "content", "
Cameleer

Your sign-in verification code:

{{code}}

This code expires in 10 minutes.

" + ), + Map.of( + "usageType", "ForgotPassword", + "contentType", "text/html", + "subject", "Reset your Cameleer password", + "content", "
Cameleer

Enter this code to reset your password:

{{code}}

This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.

" + ), + Map.of( + "usageType", "Generic", + "contentType", "text/html", + "subject", "Your Cameleer verification code", + "content", "
Cameleer

Your verification code:

{{code}}

This code expires in 10 minutes.

" + ) + )); + return config; + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java +git commit -m "feat: add EmailConnectorService for Logto email connector management" +``` + +--- + +### Task 3: Create EmailConnectorController + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java` + +- [ ] **Step 1: Create the controller** + +```java +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 get() { + var status = emailConnectorService.getEmailConnector(); + if (status == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(EmailConnectorResponse.from(status)); + } + + @PostMapping + public ResponseEntity 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 delete() { + emailConnectorService.deleteEmailConnector(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/test") + public ResponseEntity> 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 toggleRegistration(@RequestBody Map 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(); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java +git commit -m "feat: add EmailConnectorController with CRUD, test, and registration toggle endpoints" +``` + +--- + +### Task 4: Create frontend hooks + +**Files:** +- Create: `ui/src/api/email-connector-hooks.ts` + +- [ ] **Step 1: Create the hooks file** + +```typescript +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({ + queryKey: ['vendor', 'email-connector'], + queryFn: async () => { + try { + return await api.get('/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({ + mutationFn: (config) => api.post('/vendor/email-connector', config), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), + }); +} + +export function useDeleteEmailConnector() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete('/vendor/email-connector'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), + }); +} + +export function useTestEmailConnector() { + return useMutation({ + mutationFn: (to) => api.post('/vendor/email-connector/test', { to }), + }); +} + +export function useToggleRegistration() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (enabled) => api.post('/vendor/email-connector/registration', { enabled }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), + }); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/api/email-connector-hooks.ts +git commit -m "feat: add React Query hooks for email connector API" +``` + +--- + +### Task 5: Create EmailConfigPage + +**Files:** +- Create: `ui/src/pages/vendor/EmailConfigPage.tsx` + +- [ ] **Step 1: Create the page component** + +```tsx +import { useState } from 'react'; +import { + Alert, + Button, + Card, + FormField, + Input, + Spinner, + useToast, +} from '@cameleer/design-system'; +import { Mail, 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 ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch email connector data. Please refresh. + +
+ ); + } + + return ( +
+

Email Connector

+ + {!isConfigured && ( + + Self-service registration is disabled. Configure an SMTP connector to enable email + verification for sign-up and password reset. + + )} + +
+ {/* Current config card (when configured and not editing) */} + {isConfigured && !editing && ( + +
+
+ Host + {connector.host} +
+
+ Port + {connector.port} +
+
+ Username + {connector.username} +
+
+ Password + {'*'.repeat(8)} +
+
+ From Email + {connector.fromEmail} +
+
+ + {!confirmDelete ? ( + + ) : ( + <> + + + + )} +
+
+
+ )} + + {/* SMTP form (when unconfigured or editing) */} + {showForm && ( + +
+ + setHost(e.target.value)} + /> + + + setPort(e.target.value)} + /> + + + setUsername(e.target.value)} + /> + + + setPassword(e.target.value)} + /> + + + setFromEmail(e.target.value)} + /> + +
+ + {editing && ( + + )} +
+
+
+ )} + + {/* Registration toggle (when configured) */} + {isConfigured && !editing && ( + +
+

+ {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.'} +

+
+ +
+
+
+ )} + + {/* Test email (when configured and not editing) */} + {isConfigured && !editing && ( + +
+ + setTestTo(e.target.value)} + /> + + +
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/vendor/EmailConfigPage.tsx +git commit -m "feat: add EmailConfigPage with SMTP form, registration toggle, and test email" +``` + +--- + +### Task 6: Wire up router and sidebar + +**Files:** +- Modify: `ui/src/router.tsx:18` (add import) +- Modify: `ui/src/router.tsx:104` (add route) +- Modify: `ui/src/components/Layout.tsx:7` (add icon import) +- Modify: `ui/src/components/Layout.tsx:128-133` (replace Identity link with expandable section) + +- [ ] **Step 1: Add route in router.tsx** + +Add the import after line 18 (`VendorMetricsPage` import): + +```typescript +import { EmailConfigPage } from './pages/vendor/EmailConfigPage'; +``` + +Add the route after the infrastructure route block (after line 104): + +```tsx + }> + + + } /> +``` + +- [ ] **Step 2: Update sidebar in Layout.tsx** + +Add `Mail` to the lucide-react import on line 7: + +```typescript +import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail } from 'lucide-react'; +``` + +Replace the "Identity (Logto)" div at lines 128-133 with an expandable Identity section containing Email Connector and Logto Console: + +Replace this block: +```tsx +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} + > + Identity (Logto) +
+``` + +With: +```tsx +
navigate('/vendor/email')} + > + + Email Connector +
+
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} + > + Logto Console +
+``` + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/router.tsx ui/src/components/Layout.tsx +git commit -m "feat: add email connector route and sidebar navigation" +``` + +--- + +### Task 7: Remove SMTP from bootstrap + +**Files:** +- Modify: `docker/logto-bootstrap.sh:567-682` (remove Phase 8b, modify Phase 8c) + +- [ ] **Step 1: Remove Phase 8b (SMTP connector creation)** + +Delete lines 567-649 entirely — the full "PHASE 8b: Configure SMTP email connector" block including the comment header, the `if` block, the connector config JSON, the create/update logic, and the else/warning blocks. + +- [ ] **Step 2: Modify Phase 8c (registration default)** + +Replace the Phase 8c block (lines 651-682 after the deletion above will have shifted) with sign-in only mode. The new Phase 8c should be: + +```bash +# ============================================================ +# PHASE 8c: Configure sign-in experience (sign-in only) +# ============================================================ +# Registration is disabled by default. The vendor admin enables it +# via the Email Connector UI after configuring SMTP delivery. + +log "Configuring sign-in experience (sign-in only, no registration)..." +api_patch "/api/sign-in-exp" '{ + "signInMode": "SignIn", + "signIn": { + "methods": [ + { + "identifier": "username", + "password": true, + "verificationCode": false, + "isPasswordPrimary": true + } + ] + } +}' >/dev/null 2>&1 +log "Sign-in experience configured: SignIn only (registration disabled until email is configured)." +``` + +- [ ] **Step 3: Commit** + +```bash +git add docker/logto-bootstrap.sh +git commit -m "feat: remove SMTP connector from bootstrap, default to sign-in only" +``` + +--- + +### Task 8: Remove SMTP from compose template + +**Files:** +- Modify: `installer/templates/docker-compose.saas.yml:30-35` + +- [ ] **Step 1: Remove SMTP env vars from cameleer-logto service** + +Delete lines 30-35 (the SMTP comment and 5 env vars): + +```yaml + # SMTP (for email verification during registration) + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer +git add templates/docker-compose.saas.yml +git commit -m "feat: remove SMTP env vars from Logto compose template" +``` + +--- + +### Task 9: Remove SMTP from bash installer + +**Files:** +- Modify: `installer/install.sh` (multiple locations) + +- [ ] **Step 1: Remove SMTP env var captures (lines 49-53)** + +Delete: +```bash +_ENV_SMTP_HOST="${SMTP_HOST:-}" +_ENV_SMTP_PORT="${SMTP_PORT:-}" +_ENV_SMTP_USER="${SMTP_USER:-}" +_ENV_SMTP_PASS="${SMTP_PASS:-}" +_ENV_SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-}" +``` + +- [ ] **Step 2: Remove SMTP variable declarations (lines 80-84)** + +Delete: +```bash +SMTP_HOST="" +SMTP_PORT="" +SMTP_USER="" +SMTP_PASS="" +SMTP_FROM_EMAIL="" +``` + +- [ ] **Step 3: Remove SMTP CLI args (lines 197-201)** + +Delete: +```bash + --smtp-host) SMTP_HOST="$2"; shift ;; + --smtp-port) SMTP_PORT="$2"; shift ;; + --smtp-user) SMTP_USER="$2"; shift ;; + --smtp-pass) SMTP_PASS="$2"; shift ;; + --smtp-from-email) SMTP_FROM_EMAIL="$2"; shift ;; +``` + +- [ ] **Step 4: Remove SMTP from config file parsing (lines 296-300)** + +Delete: +```bash + smtp_host) [ -z "$SMTP_HOST" ] && SMTP_HOST="$value" ;; + smtp_port) [ -z "$SMTP_PORT" ] && SMTP_PORT="$value" ;; + smtp_user) [ -z "$SMTP_USER" ] && SMTP_USER="$value" ;; + smtp_pass) [ -z "$SMTP_PASS" ] && SMTP_PASS="$value" ;; + smtp_from_email) [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$value" ;; +``` + +- [ ] **Step 5: Remove SMTP env fallbacks (lines 331-335)** + +Delete: +```bash + [ -z "$SMTP_HOST" ] && SMTP_HOST="$_ENV_SMTP_HOST" + [ -z "$SMTP_PORT" ] && SMTP_PORT="$_ENV_SMTP_PORT" + [ -z "$SMTP_USER" ] && SMTP_USER="$_ENV_SMTP_USER" + [ -z "$SMTP_PASS" ] && SMTP_PASS="$_ENV_SMTP_PASS" + [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$_ENV_SMTP_FROM_EMAIL" +``` + +- [ ] **Step 6: Remove SMTP prompt block (lines 499-509)** + +Delete the entire block: +```bash + # SMTP for email verification (SaaS mode only) + if [ "$DEPLOYMENT_MODE" = "saas" ]; then + echo "" + if prompt_yesno "Configure SMTP for email verification? (required for self-service sign-up)"; then + prompt SMTP_HOST "SMTP host" "${SMTP_HOST:-}" + prompt SMTP_PORT "SMTP port" "${SMTP_PORT:-587}" + prompt SMTP_USER "SMTP username" "${SMTP_USER:-}" + prompt_password SMTP_PASS "SMTP password" "${SMTP_PASS:-}" + prompt SMTP_FROM_EMAIL "From email address" "${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}" + fi + fi +``` + +- [ ] **Step 7: Remove SMTP from .env generation (lines 780-784 and 794)** + +Delete: +```bash +# SMTP (for email verification during registration) +SMTP_HOST=${SMTP_HOST} +SMTP_PORT=${SMTP_PORT:-587} +SMTP_USER=${SMTP_USER} +SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}} +``` + +And delete: +```bash + env_val "$f" SMTP_PASS "$SMTP_PASS" +``` + +- [ ] **Step 8: Remove SMTP from cameleer.conf generation (lines 975-978 and 983)** + +Delete: +```bash +smtp_host=${SMTP_HOST} +smtp_port=${SMTP_PORT} +smtp_user=${SMTP_USER} +smtp_from_email=${SMTP_FROM_EMAIL} +``` + +And delete: +```bash + env_val "$f" smtp_pass "$SMTP_PASS" +``` + +- [ ] **Step 9: Commit** + +```bash +cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer +git add install.sh +git commit -m "feat: remove SMTP configuration from bash installer" +``` + +--- + +### Task 10: Remove SMTP from PowerShell installer + +**Files:** +- Modify: `installer/install.ps1` (multiple locations) + +- [ ] **Step 1: Remove SMTP env var reads (lines 95-99)** + +Delete: +```powershell +$_ENV_SMTP_HOST = $env:SMTP_HOST +$_ENV_SMTP_PORT = $env:SMTP_PORT +$_ENV_SMTP_USER = $env:SMTP_USER +$_ENV_SMTP_PASS = $env:SMTP_PASS +$_ENV_SMTP_FROM_EMAIL = $env:SMTP_FROM_EMAIL +``` + +- [ ] **Step 2: Remove SMTP config file parsing (lines 305-309)** + +Delete: +```powershell + 'smtp_host' { if (-not $script:cfg.SmtpHost) { $script:cfg.SmtpHost = $val } } + 'smtp_port' { if (-not $script:cfg.SmtpPort) { $script:cfg.SmtpPort = $val } } + 'smtp_user' { if (-not $script:cfg.SmtpUser) { $script:cfg.SmtpUser = $val } } + 'smtp_pass' { if (-not $script:cfg.SmtpPass) { $script:cfg.SmtpPass = $val } } + 'smtp_from_email' { if (-not $script:cfg.SmtpFromEmail) { $script:cfg.SmtpFromEmail = $val } } +``` + +- [ ] **Step 3: Remove SMTP env fallbacks (lines 342-346)** + +Delete: +```powershell + if (-not $c.SmtpHost) { $c.SmtpHost = $_ENV_SMTP_HOST } + if (-not $c.SmtpPort) { $c.SmtpPort = $_ENV_SMTP_PORT } + if (-not $c.SmtpUser) { $c.SmtpUser = $_ENV_SMTP_USER } + if (-not $c.SmtpPass) { $c.SmtpPass = $_ENV_SMTP_PASS } + if (-not $c.SmtpFromEmail) { $c.SmtpFromEmail = $_ENV_SMTP_FROM_EMAIL } +``` + +- [ ] **Step 4: Remove SMTP prompt block (lines 516-526)** + +Delete: +```powershell + # SMTP for email verification (SaaS mode only) + if ($c.DeploymentMode -eq 'saas') { + Write-Host '' + if (Prompt-YesNo 'Configure SMTP for email verification? (required for self-service sign-up)') { + $c.SmtpHost = Prompt-Value 'SMTP host' (Coalesce $c.SmtpHost '') + $c.SmtpPort = Prompt-Value 'SMTP port' (Coalesce $c.SmtpPort '587') + $c.SmtpUser = Prompt-Value 'SMTP username' (Coalesce $c.SmtpUser '') + $c.SmtpPass = Prompt-Password 'SMTP password' (Coalesce $c.SmtpPass '') + $c.SmtpFromEmail = Prompt-Value 'From email address' (Coalesce $c.SmtpFromEmail "noreply@$($c.PublicHost)") + } + } +``` + +- [ ] **Step 5: Remove SMTP from .env generation (lines 778-782 and 789)** + +Delete: +```powershell +# SMTP (for email verification during registration) +SMTP_HOST=$($c.SmtpHost) +SMTP_PORT=$(if ($c.SmtpPort) { $c.SmtpPort } else { '587' }) +SMTP_USER=$($c.SmtpUser) +SMTP_FROM_EMAIL=$(if ($c.SmtpFromEmail) { $c.SmtpFromEmail } else { "noreply@$($c.PublicHost)" }) +``` + +And delete: +```powershell + $content += "`n$(Format-EnvVal 'SMTP_PASS' $c.SmtpPass)" +``` + +- [ ] **Step 6: Remove SMTP from cameleer.conf generation (lines 1028-1031 and 1036)** + +Delete: +```powershell +smtp_host=$($c.SmtpHost) +smtp_port=$($c.SmtpPort) +smtp_user=$($c.SmtpUser) +smtp_from_email=$($c.SmtpFromEmail) +``` + +And delete: +```powershell + $txt += "`n$(Format-EnvVal 'smtp_pass' $c.SmtpPass)" +``` + +- [ ] **Step 7: Commit** + +```bash +cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer +git add install.ps1 +git commit -m "feat: remove SMTP configuration from PowerShell installer" +``` + +--- + +### Task 11: Update installer CLAUDE.md documentation + +**Files:** +- Modify: `installer/CLAUDE.md` + +- [ ] **Step 1: Remove or update the SMTP configuration section** + +Replace the "## SMTP configuration" section in `installer/CLAUDE.md` with: + +```markdown +## SMTP configuration + +SMTP / email connector configuration has been moved from the installer to the SaaS vendor admin UI (Email Connector page at `/vendor/email`). The installer no longer prompts for or persists SMTP settings. + +Previously, SMTP env vars (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`) were passed to the `cameleer-logto` container and configured via the bootstrap script. This one-shot approach was fragile — email delivery is now configured at runtime through the Logto Management API. +``` + +- [ ] **Step 2: Commit** + +```bash +cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer +git add CLAUDE.md +git commit -m "docs: update installer CLAUDE.md to reflect SMTP removal" +``` + +--- + +### Task 12: Update docker CLAUDE.md documentation + +**Files:** +- Modify: `docker/CLAUDE.md` + +- [ ] **Step 1: Update the Bootstrap section** + +In the `docker/CLAUDE.md` file, find the paragraph about SMTP env vars in the Bootstrap section: + +``` +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. +``` + +Replace with: + +``` +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. +``` + +Also update the bootstrap phases list: remove `8b. Configure SMTP email connector` and update `8c` to note it defaults to sign-in only. + +- [ ] **Step 2: Commit** + +```bash +git add docker/CLAUDE.md +git commit -m "docs: update docker CLAUDE.md to reflect SMTP bootstrap removal" +``` + +--- + +### Task 13: Manual verification + +- [ ] **Step 1: Rebuild and verify the Java backend compiles** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 2: Verify the frontend compiles** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Start the dev server and test the UI** + +Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npm run dev` + +Open the vendor console in the browser. Navigate to the sidebar — verify: +1. "Identity (Logto)" link has been replaced with "Email Connector" and "Logto Console" items +2. Clicking "Email Connector" navigates to `/platform/vendor/email` +3. The unconfigured state shows the info alert and SMTP form +4. Fill in SMTP config and save — verify the configured state shows +5. Test the "Send Test Email" functionality +6. Toggle registration on/off +7. Delete the connector — verify it returns to unconfigured state +8. "Logto Console" still opens the Logto admin in a new tab + +- [ ] **Step 4: Verify bootstrap changes** + +If you can reset the Logto bootstrap (delete `/data/logto-bootstrap.json` in the container and restart), verify that: +1. Bootstrap no longer attempts SMTP connector creation +2. Sign-in mode defaults to "SignIn" (no registration) +3. Admin user can still sign in with username+password diff --git a/docs/superpowers/specs/2026-04-25-email-connector-ui-design.md b/docs/superpowers/specs/2026-04-25-email-connector-ui-design.md new file mode 100644 index 0000000..28bc528 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-email-connector-ui-design.md @@ -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)