Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions create-a-container/client/src/app/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Container as ContainerIcon,
Globe,
KeyRound,
Mail,
Radio,
Server,
Settings,
Expand Down Expand Up @@ -61,6 +62,7 @@ const ADMIN: NavLink[] = [
},
{ to: '/agents', label: 'Agents', icon: <Radio className="size-4" />, adminOnly: true },
{ to: '/apikeys', label: 'API Keys', icon: <KeyRound className="size-4" /> },
{ to: '/mail-accounts', label: 'Email', icon: <Mail className="size-4" /> },
{ to: '/settings', label: 'Settings', icon: <Settings className="size-4" />, adminOnly: true },
];

Expand Down
3 changes: 3 additions & 0 deletions create-a-container/client/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { InviteUserPage } from '@/pages/users/InviteUserPage';
import { GroupsListPage } from '@/pages/groups/GroupsListPage';
import { GroupFormPage } from '@/pages/groups/GroupFormPage';
import { ApiKeysListPage } from '@/pages/apikeys/ApiKeysListPage';
import { MailAccountsListPage } from '@/pages/mail-accounts/MailAccountsListPage';
import { SettingsPage } from '@/pages/settings/SettingsPage';
import { ResourceRequestsPage } from '@/pages/resource-requests/ResourceRequestsPage';
import { MyRequestsPage } from '@/pages/resource-requests/MyRequestsPage';
Expand Down Expand Up @@ -84,6 +85,8 @@ export const router = createBrowserRouter([

{ path: '/apikeys', element: <ApiKeysListPage /> },

{ path: '/mail-accounts', element: <MailAccountsListPage /> },

{ path: '/settings', element: <SettingsPage /> },

{ path: '/resource-requests', element: <ResourceRequestsPage /> },
Expand Down
21 changes: 21 additions & 0 deletions create-a-container/client/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import type {
Group,
Job,
JobStatusRow,
MailAccount,
MailDomain,
MailDnsInfo,
MailHostInfo,
MailPtrInfo,
MailSuppression,
Node,
NodeStats,
AppSettings,
Expand Down Expand Up @@ -49,6 +55,11 @@ export const keys = {
groups: () => ['groups'] as const,
group: (id: number | string) => ['groups', String(id)] as const,
apikeys: () => ['apikeys'] as const,
mailAccounts: () => ['mail-accounts'] as const,
mailDomains: () => ['mail-accounts', 'domains'] as const,
mailSuppressions: (id: string) => ['mail-accounts', id, 'suppressions'] as const,
mailDns: (domainId: number | string) => ['external-domains', String(domainId), 'mail-dns'] as const,
mailHost: () => ['mail', 'host'] as const,
settings: () => ['settings'] as const,
job: (id: number | string) => ['jobs', String(id)] as const,
jobStatuses: (id: number | string) => ['jobs', String(id), 'statuses'] as const,
Expand Down Expand Up @@ -142,6 +153,16 @@ export const queries = {
// API keys
listApiKeys: () => api.get<ApiKey[]>('/api/v1/apikeys'),

// Mail accounts (issue #67)
listMailAccounts: () => api.get<MailAccount[]>('/api/v1/mail-accounts'),
listMailDomains: () => api.get<MailDomain[]>('/api/v1/mail-accounts/domains'),
listMailSuppressions: (id: string) =>
api.get<MailSuppression[]>(`/api/v1/mail-accounts/${id}/suppressions`),
getDomainMailDns: (domainId: number | string) =>
api.get<MailDnsInfo>(`/api/v1/external-domains/${domainId}/mail/dns`),
getMailHost: () => api.get<MailHostInfo>('/api/v1/agents/mail-host'),
getMailPtr: () => api.get<MailPtrInfo>('/api/v1/agents/mail-host/ptr'),

// Settings
getSettings: () => api.get<AppSettings>('/api/v1/settings'),

Expand Down
116 changes: 116 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ export interface Agent {
hostname: string;
ipv4Address: string | null;
services: Record<string, AgentServiceStatus> | null;
/** Service groups the agent runs (AGENT_SERVICES), e.g. ['nginx','dnsmasq','mail']. */
enabledServices: string[] | null;
/** Binaries missing for the enabled groups; blocks the mail-host claim. */
missingBinaries: string[] | null;
/** Non-null on the single agent holding the mail-host claim. */
mailHostSince: string | null;
isLocal: boolean;
hasApiKeyPin: boolean;
lastCheckinAt: string | null;
/** Server-computed, so it is immune to client clock drift. */
secondsSinceCheckin: number | null;
Expand Down Expand Up @@ -194,6 +202,11 @@ export interface ExternalDomain {
site: { id: number; name: string } | null;
authServer: string | null;
hasCloudflareApiKey: boolean;
/** Read-only mail state (issue #67) — changed via the …/mail endpoints. */
mailEnabled: boolean;
mailDnsVerified: boolean;
mailMxVerified: boolean;
mailDnsCheckedAt: string | null;
}

export interface ServiceHttp {
Expand Down Expand Up @@ -353,6 +366,98 @@ export interface ApiKeyCreated extends ApiKey {
warning: string;
}

/** A domain mail accounts can be created on (mail enabled + DNS verified). */
export interface MailDomain {
id: number;
name: string;
}

export interface MailAccount {
id: string;
address: string | null;
localPart: string;
domain: { id: number; name: string; canSend: boolean; canReceive: boolean } | null;
owner: string | null;
description: string | null;
enabled: boolean;
quotaMb: number;
unsubscribeHeaders: boolean;
lastRotatedAt: string | null;
createdAt: string;
updatedAt: string;
}

/** Create/rotate response — the password is returned exactly once. */
export interface MailAccountSecret extends MailAccount {
password: string;
connection: {
host: string;
username: string;
smtp: { ports: number[] };
imap: { ports: number[] };
};
}

export interface MailSuppression {
id: number;
recipient: string;
source: 'one-click' | 'admin';
createdAt: string;
}

export interface MailDnsRecord {
type: 'TXT' | 'MX';
name: string;
value: string;
}

/** One gate of the stored Check DNS outcome. */
export interface MailDnsGate {
pass: boolean;
[key: string]: unknown;
}

export interface MailDnsCheckResult {
checkedAt: string;
spf: MailDnsGate;
dkim: MailDnsGate;
dmarc: MailDnsGate;
mx: MailDnsGate;
ptr?: { name: string | null; forwardConfirmed: boolean };
warnings: string[];
}

export interface MailDnsInfo {
records: MailDnsRecord[];
mailEnabled: boolean;
mailDnsVerified: boolean;
mailMxVerified: boolean;
lastCheck: { checkedAt: string; result: MailDnsCheckResult | null } | null;
}

export interface MailHostInfo {
agent: {
id: number;
siteId: number;
siteName: string | null;
hostname: string;
mailHostSince: string;
enabledServices: string[] | null;
missingBinaries: string[] | null;
} | null;
mailIp: string | null;
mailHostname: string | null;
selfManagedSiteId: number | null;
}

export interface MailPtrInfo {
mailIp: string | null;
ptr: string | null;
forwardConfirmed: boolean;
mailHostname: string | null;
suggestion: string | null;
}

export interface AppSettings {
smtpUrl: string;
smtpNoreplyAddress: string;
Expand All @@ -363,6 +468,17 @@ export interface AppSettings {
bannerMessage: string;
/** Max PSI probes per usage report; '' uses the server default (16), '0' disables. */
usagePsiProbeLimit: string;
/** Mail service (issue #67). All stored as strings; '' means unset. */
mailHostname: string;
mailRelayhost: string;
mailRelayhostUsername: string;
mailRelayhostPassword: string;
mailSpfInclude: string;
mailDnsCheckResolvers: string;
mailDbHost: string;
mailSelfManagedSiteId: string;
mailDefaultQuotaMb: string;
mailMessageSizeLimitMb: string;
}

export type ResourceType = 'memory' | 'swap' | 'cpus' | 'rootfs';
Expand Down
76 changes: 76 additions & 0 deletions create-a-container/client/src/pages/agents/AgentRowActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, useToast } from '@mieweb/ui';
import { KeyRound, MailMinus } from 'lucide-react';
import { api, ApiError } from '@/lib/api';
import { keys } from '@/lib/queries';
import type { Agent } from '@/lib/types';

/** Admin actions on one agent row: release the mail-host claim (holder only)
* and clear the API-key pin (remote agents re-pin on their next check-in). */
export function AgentRowActions({ agent }: { agent: Agent }) {
const qc = useQueryClient();
const toast = useToast();
const invalidate = () => {
qc.invalidateQueries({ queryKey: keys.agents() });
qc.invalidateQueries({ queryKey: keys.mailHost() });
};

const release = useMutation({
mutationFn: () => api.delete('/api/v1/agents/mail-host'),
onSuccess: () => {
toast.success('Mail-host claim released — the next qualifying check-in takes it');
invalidate();
},
onError: (err: ApiError) => toast.error(err.message),
});

const clearPin = useMutation({
mutationFn: () => api.delete(`/api/v1/agents/${agent.id}/api-key-pin`),
onSuccess: () => {
toast.success('API-key pin cleared');
invalidate();
},
onError: (err: ApiError) => toast.error(err.message),
});

if (!agent.mailHostSince && !agent.hasApiKeyPin) {
return <span className="text-muted-foreground">—</span>;
}

return (
<div className="flex flex-wrap justify-end gap-2">
{agent.mailHostSince && (
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
leftIcon={<MailMinus className="size-4" />}
onClick={() => {
if (confirm(`Release the mail-host claim held by ${agent.hostname}? Its mail services stop; mailboxes stay in place.`)) {
release.mutate();
}
}}
disabled={release.isPending}
>
Release mail host
</Button>
)}
{agent.hasApiKeyPin && (
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
leftIcon={<KeyRound className="size-4" />}
onClick={() => {
if (confirm(`Clear the API-key pin for ${agent.hostname}? It re-pins to whatever key its next check-in presents.`)) {
clearPin.mutate();
}
}}
disabled={clearPin.isPending}
>
Clear key pin
</Button>
)}
</div>
);
}
10 changes: 10 additions & 0 deletions create-a-container/client/src/pages/agents/AgentsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { keys, queries } from '@/lib/queries';
import type { Agent } from '@/lib/types';
import { useDocumentTitle } from '@/lib/useDocumentTitle';
import { AgentServiceBadges } from './AgentServiceBadges';
import { AgentRowActions } from './AgentRowActions';
import { MailHostBadge } from './MailHostBadge';
import { OnlineBadge } from './OnlineBadge';

export function AgentsListPage() {
Expand Down Expand Up @@ -65,7 +67,9 @@ export function AgentsListPage() {
<TableHead>IPv4</TableHead>
<TableHead>Status</TableHead>
<TableHead>Services</TableHead>
<TableHead>Mail</TableHead>
<TableHead>Last check-in</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -80,7 +84,13 @@ export function AgentsListPage() {
<TableCell>
<AgentServiceBadges services={agent.services} />
</TableCell>
<TableCell>
<MailHostBadge agent={agent} />
</TableCell>
<TableCell>{formatRelativeTime(agent.lastCheckinAt, agent.secondsSinceCheckin)}</TableCell>
<TableCell className="text-right">
<AgentRowActions agent={agent} />
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
28 changes: 28 additions & 0 deletions create-a-container/client/src/pages/agents/MailHostBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Badge } from '@mieweb/ui';
import type { Agent } from '@/lib/types';

/** Mail-host claim state for one agent row. */
export function MailHostBadge({ agent }: { agent: Agent }) {
if (agent.mailHostSince) {
return (
<Badge variant="success" title={`Mail host since ${new Date(agent.mailHostSince).toLocaleString()}`}>
Mail host
</Badge>
);
}
if (!agent.enabledServices?.includes('mail')) {
return <span className="text-muted-foreground">—</span>;
}
if (agent.missingBinaries && agent.missingBinaries.length > 0) {
return (
<Badge variant="warning" title={`Install on the agent host: ${agent.missingBinaries.join(', ')}`}>
Missing packages
</Badge>
);
}
return (
<Badge variant="secondary" title="Reports the mail group but another agent holds the claim">
Standby
</Badge>
);
}
Loading
Loading