Files
cameleer-saas/docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md

1372 lines
48 KiB
Markdown
Raw Normal View History

# 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<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);
}
```
- [ ] **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<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;
}
}
```
- [ ] **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<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();
}
}
```
- [ ] **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<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'] }),
});
}
```
- [ ] **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 (
<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>
);
}
```
- [ ] **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
<Route path="/vendor/email" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<EmailConfigPage />
</RequireScope>
} />
```
- [ ] **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
<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)
</div>
```
With:
```tsx
<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')}
>
Logto Console
</div>
```
- [ ] **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