Added admin mode, read only status for inactive accounts, and resuce verification

This commit is contained in:
Corey Blais
2026-04-15 16:33:07 -04:00
parent 43c32a5efc
commit 784a911dc2
12 changed files with 816 additions and 109 deletions
+406 -54
View File
@@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female';
@@ -50,6 +52,8 @@ type Workspace = {
workspaceType: WorkspaceType;
billingEmail: string | null;
billingPlan: BillingPlan;
subscriptionStatus: SubscriptionStatus;
rescueVerificationStatus: RescueVerificationStatus;
createdAt: string;
updatedAt: string;
};
@@ -89,9 +93,26 @@ type AuthSessionPayload = {
activeWorkspace: Workspace;
activeMembership: WorkspaceMember;
workspaces: WorkspaceSummary[];
isAdmin: boolean;
providers: AuthProvider[];
};
type AdminSummary = {
totalBirds: number;
totalUsers: number;
totalWorkspaces: number;
rescueWorkspaces: number;
pendingRescues: number;
dailyUsers: number;
};
type AdminRescueWorkspace = {
workspace: Workspace;
ownerEmail: string | null;
birdCount: number;
memberCount: number;
};
type IntegrationTokenSummary = {
id: string;
userId: string;
@@ -201,7 +222,7 @@ type PhotoDragState = {
startOffsetY: number;
};
type AppPage = 'overview' | 'flock' | 'settings';
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
@@ -229,7 +250,7 @@ const emptyWorkspaceForm: WorkspaceFormState = {
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
name: '',
email: '',
role: 'staff',
role: 'caregiver',
};
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
@@ -476,18 +497,18 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => {
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'No billing is applied to rescue workspaces.';
return 'No billing is applied to rescue flocks.';
}
if (billingPlan === 'household_basic') {
return 'Permits up to 4 birds in the workspace.';
return 'Permits up to 4 birds in the flock.';
}
if (billingPlan === 'household_plus') {
return 'Permits 5 to 10 birds in the workspace.';
return 'Permits 5 to 10 birds in the flock.';
}
return 'Permits 11 or more birds in the workspace.';
return 'Permits 11 or more birds in the flock.';
};
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
@@ -506,6 +527,51 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
return null;
};
const formatSubscriptionStatus = (status: SubscriptionStatus) => {
if (status === 'trialing') {
return 'Trialing';
}
if (status === 'past_due') {
return 'Past due';
}
if (status === 'canceled') {
return 'Canceled';
}
if (status === 'unpaid') {
return 'Unpaid';
}
if (status === 'none') {
return 'No subscription';
}
return 'Active';
};
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
if (status === 'approved') {
return 'Approved';
}
if (status === 'rejected') {
return 'Rejected';
}
if (status === 'not_required') {
return 'Not required';
}
return 'Pending verification';
};
const formatWorkspaceRole = (role: WorkspaceRole) => {
if (role === 'owner') {
return 'Owner';
}
if (role === 'assistant') {
return 'Assistant';
}
if (role === 'caregiver') {
return 'Caregiver';
}
return 'Viewer';
};
const readFileAsDataUrl = async (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -747,6 +813,8 @@ function App() {
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>('');
@@ -771,6 +839,7 @@ function App() {
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
@@ -795,6 +864,8 @@ function App() {
notes: '',
});
const [deletingBird, setDeletingBird] = useState(false);
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
@@ -1056,6 +1127,8 @@ function App() {
setActiveMembership(null);
setWorkspaceMembers([]);
setIntegrationTokens([]);
setAdminSummary(null);
setAdminRescueWorkspaces([]);
setBirds([]);
setWeights([]);
setVetVisits([]);
@@ -1192,6 +1265,35 @@ function App() {
void loadWorkspaceData();
}, [authToken, workspace?.id]);
useEffect(() => {
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
return;
}
const loadAdminDashboard = async () => {
try {
const [summaryResponse, rescuesResponse] = await Promise.all([
apiFetch('/admin/summary', authToken),
apiFetch('/admin/rescue-workspaces', authToken),
]);
if (!summaryResponse.ok || !rescuesResponse.ok) {
throw new Error('Unable to load admin dashboard.');
}
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
setAdminSummary(summaryData.summary ?? null);
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
} catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
}
};
void loadAdminDashboard();
}, [activePage, authSession?.isAdmin, authToken]);
useEffect(() => {
if (!selectedBird?.id) {
setWeights([]);
@@ -1215,6 +1317,8 @@ function App() {
setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []);
setEditingVetVisitId('');
setDeletingVetVisitId('');
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
}
@@ -1355,13 +1459,13 @@ function App() {
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.'));
throw new Error(await readErrorMessage(response, 'Unable to switch flocks.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to switch workspaces.');
throw new Error('Unable to switch flocks.');
}
const nextToken = data.token || authToken;
@@ -1373,7 +1477,7 @@ function App() {
setVetVisits([]);
setActivePage('overview');
} catch (switchError) {
setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.');
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
} finally {
setSwitchingWorkspaceId(null);
}
@@ -1447,6 +1551,53 @@ function App() {
}
};
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
if (!authToken) {
return;
}
setError('');
setUpdatingRescueWorkspaceId(workspaceId);
try {
const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rescueVerificationStatus }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to update rescue verification status.');
}
setAdminRescueWorkspaces((current) =>
current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)),
);
setAdminSummary((current) =>
current
? {
...current,
pendingRescues: adminRescueWorkspaces.filter((entry) =>
entry.workspace.id === workspaceId
? rescueVerificationStatus === 'pending'
: entry.workspace.rescueVerificationStatus === 'pending',
).length,
}
: current,
);
} catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.');
} finally {
setUpdatingRescueWorkspaceId(null);
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -1470,17 +1621,17 @@ function App() {
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create workspace.'));
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
}
const workspaceResponse = await apiFetch('/auth/session', authToken);
if (!workspaceResponse.ok) {
throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.'));
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your workspace list.');
throw new Error('Unable to refresh your flock list.');
}
const nextToken = data.token || authToken;
@@ -1491,7 +1642,7 @@ function App() {
billingEmail: data.session.user.email,
});
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.');
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
} finally {
setCreatingWorkspace(false);
}
@@ -1832,22 +1983,29 @@ function App() {
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
});
const isEditingVetVisit = Boolean(editingVetVisitId);
const response = await apiFetch(
isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
authToken,
{
method: isEditingVetVisit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
},
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`));
}
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) {
throw new Error('Unable to save vet visit.');
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
}
setVetVisits((current) =>
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
(isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort(
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
),
);
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
@@ -1855,11 +2013,61 @@ function App() {
reason: '',
notes: '',
});
setEditingVetVisitId('');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
}
};
const handleEditVetVisit = (visit: VetVisit) => {
setEditingVetVisitId(visit.id);
setVetVisitForm({
visitedOn: visit.visitedOn,
clinicName: visit.clinicName,
reason: visit.reason,
notes: visit.notes ?? '',
});
setError('');
};
const handleCancelVetVisitEdit = () => {
setEditingVetVisitId('');
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
};
const handleDeleteVetVisit = async (visitId: string) => {
if (!selectedBird || deletingVetVisitId) {
return;
}
setDeletingVetVisitId(visitId);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
}
setVetVisits((current) => current.filter((visit) => visit.id !== visitId));
if (editingVetVisitId === visitId) {
handleCancelVetVisitEdit();
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.');
} finally {
setDeletingVetVisitId('');
}
};
const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) {
return;
@@ -1895,6 +2103,8 @@ function App() {
setSelectedBirdId('');
setWeights([]);
setVetVisits([]);
setEditingVetVisitId('');
setDeletingVetVisitId('');
if (editingBirdId === selectedBird.id) {
setEditingBirdId('');
@@ -1967,13 +2177,13 @@ function App() {
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.'));
throw new Error(await readErrorMessage(response, 'Unable to save flock settings.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to save workspace settings.');
throw new Error('Unable to save flock settings.');
}
const savedWorkspace = data.workspace;
@@ -1997,7 +2207,7 @@ function App() {
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
});
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.');
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
} finally {
setSavingWorkspace(false);
}
@@ -2074,7 +2284,7 @@ function App() {
<div>
<p className="eyebrow">FlockPal</p>
<h1>Loading your flock spaces...</h1>
<p className="muted">Checking your sign-in and workspace access.</p>
<p className="muted">Checking your sign-in and flock access.</p>
</div>
</section>
</main>
@@ -2204,6 +2414,11 @@ function App() {
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings
</button>
{authSession.isAdmin ? (
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
Admin
</button>
) : null}
</div>
{showWorkspaceSwitcher ? (
@@ -2219,7 +2434,7 @@ function App() {
>
<span>{entry.workspace.name}</span>
<small>
{formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role}
{formatBillingPlanName(entry.workspace.billingPlan)} {formatWorkspaceRole(entry.membership.role)}
</small>
</button>
))}
@@ -2365,6 +2580,102 @@ function App() {
</section>
) : null}
{activePage === 'admin' && authSession.isAdmin ? (
<section className="stack-grid">
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Admin</p>
<h2>Platform pulse</h2>
</div>
<p className="muted">Operational counts for the full FlockPal platform.</p>
</div>
<div className="summary-grid">
<article className="summary-card">
<span>Total birds</span>
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Daily users</span>
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Total users</span>
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Flocks</span>
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Rescue flocks</span>
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Pending rescues</span>
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
</article>
</div>
</article>
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Verification</p>
<h2>Rescue flocks</h2>
</div>
<p className="muted">Pending rescues are read-only until approved.</p>
</div>
<div className="recent-list">
{adminRescueWorkspaces.length ? (
adminRescueWorkspaces.map((entry) => (
<article key={entry.workspace.id} className="vet-visit-card">
<strong>{entry.workspace.name}</strong>
<span>
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} {entry.birdCount} birds {entry.memberCount} members
</span>
<small>
Owner {entry.ownerEmail ?? 'unknown'} Billing {entry.workspace.billingEmail ?? 'not set'}
</small>
<div className="button-row">
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
>
Approve
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
>
Mark pending
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
>
Reject
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No rescue flocks yet</strong>
<small>New rescue flocks will appear here for verification review.</small>
</article>
)}
</div>
</article>
</section>
) : null}
{activePage === 'flock' ? (
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
@@ -2723,9 +3034,16 @@ function App() {
placeholder="Exam notes, medications, follow-ups, or restrictions"
/>
</label>
<button className="primary-button" type="submit">
Save vet visit
</button>
<div className="button-row wide-field">
<button className="primary-button" type="submit">
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
</button>
{editingVetVisitId ? (
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">
@@ -2737,6 +3055,21 @@ function App() {
{formatDate(visit.visitedOn)} {visit.clinicName}
</span>
<small>{visit.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
Edit
</button>
{editingVetVisitId === visit.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteVetVisit(visit.id)}
type="button"
disabled={deletingVetVisitId === visit.id}
>
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
</article>
))
) : (
@@ -2761,21 +3094,21 @@ function App() {
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Workspace</p>
<h2>Workspace profile and billing</h2>
<p className="eyebrow">Flock</p>
<h2>Flock profile and billing</h2>
</div>
</div>
<p className="muted">
Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
participating in a rescue workspace without mixing billing or bird ownership.
Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
participating in a rescue flock without mixing billing or bird ownership.
</p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
<label>
Workspace name
Flock name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
</label>
<label>
Workspace type
Flock type
<select
value={workspaceForm.workspaceType}
onChange={(event) =>
@@ -2789,6 +3122,15 @@ function App() {
<option value="rescue">Rescue</option>
</select>
</label>
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
<article className="summary-card summary-alert-card">
<strong>Approval required before edits continue</strong>
<span>
Changing this household flock to a rescue flock will make it read-only until FlockPal approves the rescue verification.
Monitor the email address used to sign up for any follow-up details needed to approve rescue status.
</span>
</article>
) : null}
{workspaceForm.workspaceType === 'standard' ? (
<>
<label>
@@ -2815,7 +3157,7 @@ function App() {
) : (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>Rescue workspaces stay free while still supporting shared team access.</span>
<span>Rescue flocks stay free while still supporting shared team access.</span>
</article>
)}
<label>
@@ -2828,7 +3170,7 @@ function App() {
/>
</label>
<button className="primary-button" type="submit" disabled={savingWorkspace}>
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'}
{savingWorkspace ? 'Saving flock...' : 'Save flock settings'}
</button>
</form>
</article>
@@ -2843,8 +3185,18 @@ function App() {
<div className="summary-grid">
<article className="summary-card">
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
</article>
<article className="summary-card">
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
<span>Flock write access will follow subscription health once billing is connected.</span>
</article>
{workspace?.workspaceType === 'rescue' ? (
<article className="summary-card">
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
<span>Rescue flocks are read-only until an admin approves their verification.</span>
</article>
) : null}
<article className="summary-card">
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span>
@@ -2858,7 +3210,7 @@ function App() {
<span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.'
: 'Current flock count in this workspace.'}
: 'Current bird count in this flock.'}
</span>
</article>
<article className="summary-card">
@@ -2872,7 +3224,7 @@ function App() {
<div className="panel-header">
<div>
<p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2>
<h2>Shared flock access</h2>
</div>
<button
className="secondary-button"
@@ -2888,7 +3240,7 @@ function App() {
{expandedSettingsSection === 'collaborators' ? (
<>
<p className="muted">
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support
Invite other people to help manage this flock. Rescue flocks support teams, and household flocks can also support
co-caregivers without changing who owns the billing.
</p>
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
@@ -2921,8 +3273,8 @@ function App() {
}
>
<option value="owner">Owner</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
</select>
</label>
@@ -2937,7 +3289,7 @@ function App() {
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{member.role} {member.email || member.inviteEmail}
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button
@@ -2953,7 +3305,7 @@ function App() {
) : (
<article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong>
<small>Add the people who should be able to help care for birds in this workspace.</small>
<small>Add the people who should be able to help care for birds in this flock.</small>
</article>
)}
</div>
@@ -2981,7 +3333,7 @@ function App() {
{expandedSettingsSection === 'integration-tokens' ? (
<>
<p className="muted">
Create a workspace-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
</p>
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
<label>
@@ -3056,7 +3408,7 @@ function App() {
) : (
<article className="vet-visit-card empty-card">
<strong>No integration tokens yet</strong>
<small>Create one for n8n, scripts, or other personal automations tied to this workspace.</small>
<small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
</article>
)}
</div>
@@ -3067,7 +3419,7 @@ function App() {
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">New workspace</p>
<p className="eyebrow">New flock</p>
<h2>Add another flock space</h2>
</div>
<button
@@ -3084,12 +3436,12 @@ function App() {
{expandedSettingsSection === 'new-workspace' ? (
<>
<p className="muted">
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each flock stays separate
for access and billing.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Workspace name
Flock name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
@@ -3097,7 +3449,7 @@ function App() {
/>
</label>
<label>
Workspace type
Flock type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) =>
@@ -3137,7 +3489,7 @@ function App() {
) : (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span>
<span>No billing is applied to rescue flocks.</span>
</article>
)}
<label>
@@ -3150,7 +3502,7 @@ function App() {
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button>
</form>
</>