added full api

This commit is contained in:
blaisadmin
2026-04-14 22:41:17 -04:00
parent e0ab66d21a
commit 37c8265320
17 changed files with 3146 additions and 978 deletions
+236 -4
View File
@@ -6,6 +6,7 @@ type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'house
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type IntegrationTokenScope = 'read_only' | 'read_write';
type Bird = {
id: string;
@@ -89,6 +90,25 @@ type AuthSessionPayload = {
providers: AuthProvider[];
};
type IntegrationTokenSummary = {
id: string;
userId: string;
workspaceId: number;
name: string;
tokenPrefix: string;
scope: IntegrationTokenScope;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
};
type IntegrationTokenFormState = {
name: string;
scope: IntegrationTokenScope;
expiresInDays: string;
};
type BirdFormState = {
name: string;
tagId: string;
@@ -179,7 +199,7 @@ type PhotoDragState = {
};
type AppPage = 'overview' | 'flock' | 'settings';
type SettingsSection = 'collaborators' | 'new-workspace' | 'flock-member' | 'transfer';
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token';
@@ -220,6 +240,12 @@ const emptyAuthForm: AuthFormState = {
email: '',
};
const emptyIntegrationTokenForm: IntegrationTokenFormState = {
name: '',
scope: 'read_write',
expiresInDays: '',
};
const defaultAuthProviders: AuthProvider[] = [
{ providerKey: 'google', displayName: 'Google', enabled: false },
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
@@ -308,6 +334,20 @@ const formatShortDate = (value: string | null) => {
}).format(new Date(`${value}T00:00:00`));
};
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Never';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
@@ -681,6 +721,7 @@ function App() {
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>('');
@@ -692,6 +733,7 @@ function App() {
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
const [birdPhotoName, setBirdPhotoName] = useState('');
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
@@ -701,6 +743,9 @@ function App() {
const [savingWorkspace, setSavingWorkspace] = useState(false);
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
@@ -960,6 +1005,7 @@ function App() {
setAuthSession(session);
setAuthProviders(session.providers);
setAuthNotice(null);
setNewIntegrationTokenSecret('');
setWorkspace(session.activeWorkspace);
setActiveMembership({
...session.activeMembership,
@@ -984,6 +1030,7 @@ function App() {
setWorkspace(null);
setActiveMembership(null);
setWorkspaceMembers([]);
setIntegrationTokens([]);
setBirds([]);
setWeights([]);
setVetVisits([]);
@@ -992,6 +1039,8 @@ function App() {
setEditingBirdId('');
setWorkspaceForm(emptyWorkspaceForm);
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
setIntegrationTokenForm(emptyIntegrationTokenForm);
setNewIntegrationTokenSecret('');
setAuthNotice(null);
};
@@ -1065,10 +1114,14 @@ function App() {
return;
}
const loadBirds = async () => {
const loadWorkspaceData = async () => {
try {
setLoading(true);
const [birdsResponse, membersResponse] = await Promise.all([apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken)]);
const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([
apiFetch('/birds', authToken),
apiFetch('/workspace/members', authToken),
apiFetch('/integration-tokens', authToken),
]);
if (!birdsResponse.ok) {
if (birdsResponse.status === 401) {
@@ -1096,6 +1149,14 @@ function App() {
} else {
setWorkspaceMembers([]);
}
if (integrationTokensResponse.ok) {
const integrationTokensData =
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
} else {
setIntegrationTokens([]);
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
} finally {
@@ -1103,7 +1164,7 @@ function App() {
}
};
void loadBirds();
void loadWorkspaceData();
}, [authToken, workspace?.id]);
useEffect(() => {
@@ -1293,6 +1354,74 @@ function App() {
}
};
const handleCreateIntegrationToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setCreatingIntegrationToken(true);
try {
const response = await apiFetch('/integration-tokens', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: integrationTokenForm.name.trim(),
scope: integrationTokenForm.scope,
expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create integration token.'));
}
const data =
(await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {};
if (!data.integrationToken || !data.token) {
throw new Error('Unable to create integration token.');
}
setIntegrationTokens((current) => [data.integrationToken!, ...current]);
setIntegrationTokenForm(emptyIntegrationTokenForm);
setNewIntegrationTokenSecret(data.token);
setExpandedSettingsSection('integration-tokens');
} catch (integrationTokenError) {
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.');
} finally {
setCreatingIntegrationToken(false);
}
};
const handleRevokeIntegrationToken = async (tokenId: string) => {
if (!authToken) {
return;
}
setError('');
setRevokingIntegrationTokenId(tokenId);
try {
const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.'));
}
setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId));
} catch (integrationTokenError) {
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.');
} finally {
setRevokingIntegrationTokenId('');
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -2785,6 +2914,109 @@ function App() {
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Automation</p>
<h2>Integration tokens</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'integration-tokens' ? null : 'integration-tokens'))
}
type="button"
aria-expanded={expandedSettingsSection === 'integration-tokens'}
>
{expandedSettingsSection === 'integration-tokens' ? 'Close' : 'Open'}
</button>
</div>
{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.
</p>
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
<label>
Token name
<input
value={integrationTokenForm.name}
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, name: event.target.value })}
placeholder="n8n household sync"
required
/>
</label>
<label>
Access level
<select
value={integrationTokenForm.scope}
onChange={(event) =>
setIntegrationTokenForm({
...integrationTokenForm,
scope: event.target.value as IntegrationTokenFormState['scope'],
})
}
>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<label>
Expire after days
<input
type="number"
min="1"
max="3650"
value={integrationTokenForm.expiresInDays}
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, expiresInDays: event.target.value })}
placeholder="Optional"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingIntegrationToken}>
{creatingIntegrationToken ? 'Creating token...' : 'Create integration token'}
</button>
</form>
{newIntegrationTokenSecret ? (
<article className="summary-card integration-token-secret">
<strong>Copy this token now</strong>
<span>It will not be shown again after you leave this page or create another token.</span>
<input readOnly value={newIntegrationTokenSecret} onFocus={(event) => event.currentTarget.select()} />
</article>
) : null}
<div className="recent-list">
{integrationTokens.length ? (
integrationTokens.map((token) => (
<article key={token.id} className="vet-visit-card">
<strong>{token.name}</strong>
<span>
{token.tokenPrefix}... {token.scope === 'read_only' ? 'read only' : 'read and write'}
</span>
<small>
Last used {formatDateTime(token.lastUsedAt)} Expires {token.expiresAt ? formatDateTime(token.expiresAt) : 'Never'}
</small>
<button
className="secondary-button"
onClick={() => handleRevokeIntegrationToken(token.id)}
type="button"
disabled={revokingIntegrationTokenId === token.id}
>
{revokingIntegrationTokenId === token.id ? 'Revoking...' : 'Revoke'}
</button>
</article>
))
) : (
<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>
</article>
)}
</div>
</>
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>