added full api
This commit is contained in:
+236
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user