fixing rescue settings

This commit is contained in:
blaisadmin
2026-04-15 22:57:34 -04:00
parent ce2b7a15bf
commit 765d6c61db
8 changed files with 476 additions and 11 deletions
+96
View File
@@ -36,6 +36,7 @@ import {
listBirds, listBirds,
listVetVisitsForBird, listVetVisitsForBird,
listWeightsForBird, listWeightsForBird,
transferBirdToWorkspace,
updateBird, updateBird,
updateVetVisitForBird, updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
@@ -45,7 +46,10 @@ import {
claimWorkspaceInvites, claimWorkspaceInvites,
createWorkspace, createWorkspace,
deleteWorkspaceMember, deleteWorkspaceMember,
deleteWorkspaceIfEmpty,
ensurePersonalWorkspaceForUser, ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
getWorkspaceBirdCount,
getPlatformAdminSummary, getPlatformAdminSummary,
getMembershipForUser, getMembershipForUser,
getNextWorkspaceId, getNextWorkspaceId,
@@ -158,6 +162,10 @@ const transferDraftSchema = z.object({
notes: z.string().trim().max(1000).optional().or(z.literal('')), notes: z.string().trim().max(1000).optional().or(z.literal('')),
}); });
const flockTransferSchema = z.object({
targetWorkspaceId: z.coerce.number().int().positive(),
});
const birdSchema = z.object({ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80), tagId: z.string().trim().min(1).max(80),
@@ -1226,6 +1234,55 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(
} }
}); });
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
try {
if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) {
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
return;
}
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
if (!nextWorkspaceId) {
const fallbackWorkspaceId = await getNextWorkspaceId();
const fallbackWorkspace = await createWorkspace({
id: fallbackWorkspaceId,
name: `${req.auth!.user.name}'s Flock`,
workspaceType: 'standard',
billingEmail: req.auth!.user.email,
billingPlan: 'household_basic',
owner: req.auth!.user,
});
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
}
await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId);
const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id);
if (!deletion.deleted) {
await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id);
res.status(404).json({ error: 'Flock not found.' });
return;
}
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
if (!updatedAuth) {
throw new Error('Unable to reload session.');
}
res.json({
deletedWorkspaceId: req.auth!.workspace.id,
token: req.auth!.token,
session: await buildSessionPayload(updatedAuth),
});
} catch (error) {
next(error);
}
});
app.post( app.post(
'/api/workspace/rescue-status/cancel', '/api/workspace/rescue-status/cancel',
requireAuth, requireAuth,
@@ -1345,6 +1402,45 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
} }
}); });
app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = flockTransferSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() });
return;
}
if (parsed.data.targetWorkspaceId === req.auth!.workspace.id) {
res.status(400).json({ error: 'Choose a different destination flock.' });
return;
}
try {
const targetMembership = await getMembershipForUser(req.auth!.user.id, parsed.data.targetWorkspaceId);
if (!targetMembership) {
res.status(403).json({ error: 'You do not have access to that destination flock.' });
return;
}
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, parsed.data.targetWorkspaceId);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(bird) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
return;
}
next(error);
}
});
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body); const parsed = birdSchema.safeParse(req.body);
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createBird, getBirdById, listWeightsForBird } from './birdRepository.js'; import { createBird, getBirdById, listWeightsForBird, transferBirdToWorkspace } from './birdRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
test('getBirdById returns null when the bird does not exist in the workspace', async () => { test('getBirdById returns null when the bird does not exist in the workspace', async () => {
@@ -70,3 +70,34 @@ test('listWeightsForBird scopes by bird, workspace, and day window', async () =>
assert.deepEqual(calls[0].params, ['bird-1', 30, 10]); assert.deepEqual(calls[0].params, ['bird-1', 30, 10]);
assert.match(calls[0].text, /FROM weight_records/); assert.match(calls[0].text, /FROM weight_records/);
}); });
test('transferBirdToWorkspace moves the bird to the target workspace', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'bird-1',
workspace_id: 22,
name: 'Kiwi',
tag_id: 'A-1',
species: 'Cockatiel',
gender: 'female',
date_of_birth: null,
gotcha_day: null,
chart_color: '#cb3a35',
photo_data_url: null,
notify_on_dob: false,
notify_on_gotcha_day: false,
created_at: '2026-04-14T00:00:00.000Z',
latest_weight_grams: '92',
latest_recorded_on: '2026-04-14',
},
],
});
const bird = await transferBirdToWorkspace('bird-1', 10, 22);
assert.equal(bird?.workspace_id, 22);
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
assert.match(calls[0].text, /UPDATE birds/);
});
@@ -168,6 +168,33 @@ export const deleteBird = async (birdId: string, workspaceId: number) => {
return Boolean(result.rowCount); return Boolean(result.rowCount);
}; };
export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: number, targetWorkspaceId: number) => {
const result = await db.query<BirdRow>(
`UPDATE birds
SET workspace_id = $3
WHERE id = $1
AND workspace_id = $2
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[birdId, sourceWorkspaceId, targetWorkspaceId],
);
return result.rows[0] ?? null;
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>( const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes `SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createWorkspace, ensurePersonalWorkspaceForUser, updateWorkspace } from './workspaceRepository.js'; import { createWorkspace, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, updateWorkspace } from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
import type { UserRow } from '../types.js'; import type { UserRow } from '../types.js';
@@ -95,3 +95,49 @@ test('updateWorkspace converts an existing household flock to rescue without ins
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/); assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']); assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']);
}); });
test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => {
const { calls } = mockDb(
{
rowCount: 1,
rows: [{ count: '2' }],
},
);
const result = await deleteWorkspaceIfEmpty(42);
assert.deepEqual(result, { deleted: false, reason: 'birds_present' });
assert.equal(calls.length, 1);
assert.match(calls[0].text, /FROM birds/);
});
test('deleteWorkspaceIfEmpty deletes an empty workspace', async () => {
const { calls } = mockDb(
{
rowCount: 1,
rows: [{ count: '0' }],
},
{
rowCount: 1,
rows: [{ id: 42 }],
},
);
const result = await deleteWorkspaceIfEmpty(42);
assert.deepEqual(result, { deleted: true });
assert.equal(calls.length, 2);
assert.match(calls[1].text, /DELETE FROM workspaces/);
});
test('findAlternateWorkspaceForUser returns another workspace when available', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ workspace_id: 84 }],
});
const workspaceId = await findAlternateWorkspaceForUser('user-1', 42);
assert.equal(workspaceId, 84);
assert.deepEqual(calls[0].params, ['user-1', 42]);
});
@@ -236,6 +236,50 @@ export const listWorkspaceMembers = async (workspaceId: number) => {
return result.rows; return result.rows;
}; };
export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => {
const result = await db.query<{ workspace_id: number }>(
`SELECT workspace_id
FROM workspace_members
WHERE user_id = $1
AND workspace_id <> $2
ORDER BY created_at ASC
LIMIT 1`,
[userId, excludeWorkspaceId],
);
return result.rows[0] ? Number(result.rows[0].workspace_id) : null;
};
export const getWorkspaceBirdCount = async (workspaceId: number) => {
const birdCount = await db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM birds
WHERE workspace_id = $1`,
[workspaceId],
);
return Number(birdCount.rows[0]?.count ?? 0);
};
export const deleteWorkspaceIfEmpty = async (workspaceId: number) => {
if ((await getWorkspaceBirdCount(workspaceId)) > 0) {
return { deleted: false as const, reason: 'birds_present' as const };
}
const deleted = await db.query<{ id: number }>(
`DELETE FROM workspaces
WHERE id = $1
RETURNING id`,
[workspaceId],
);
if (!deleted.rowCount) {
return { deleted: false as const, reason: 'not_found' as const };
}
return { deleted: true as const };
};
export const upsertWorkspaceMember = async ({ export const upsertWorkspaceMember = async ({
workspaceId, workspaceId,
inviteEmail, inviteEmail,
+58
View File
@@ -634,6 +634,31 @@ Response `200`:
} }
``` ```
#### `DELETE /api/workspace`
Requires a browser session and role `owner`. Deletes the active flock only when it has no birds.
Behavior:
- if the flock still has birds, deletion is blocked
- collaborators, sessions, and integration tokens tied to the flock are removed with it
- the backend switches the user to another existing flock, or creates a new personal flock automatically if needed
Response `200`:
```json
{
"deletedWorkspaceId": 1001,
"token": "raw-session-token",
"session": {}
}
```
Possible errors:
- `409` if birds are still assigned to the flock
- `404` if the flock no longer exists
#### `GET /api/workspace/members` #### `GET /api/workspace/members`
Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint. Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint.
@@ -749,6 +774,39 @@ Possible errors:
- `404` if the bird does not exist in the active workspace - `404` if the bird does not exist in the active workspace
- `409` if the workspace already uses that `tagId` - `409` if the workspace already uses that `tagId`
#### `POST /api/birds/:birdId/transfer`
Requires a browser session, write access, and role `owner` or `assistant`. Moves a bird from the active flock to another flock the same user can access.
Request body:
```json
{
"targetWorkspaceId": 1002
}
```
Notes:
- the destination flock must be different from the current flock
- the signed-in user must already be a member of the destination flock
- the bird keeps its existing weight and vet history because the record itself is reassigned
Response `200`:
```json
{
"bird": {}
}
```
Possible errors:
- `400` if the destination flock is the current flock or the payload is invalid
- `403` if the user does not have access to the destination flock
- `404` if the bird is not in the active flock
- `409` if the destination flock already has a bird using the same `tagId`
#### `DELETE /api/birds/:birdId` #### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+166 -9
View File
@@ -548,7 +548,7 @@ const formatSubscriptionStatus = (status: SubscriptionStatus) => {
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
if (status === 'approved') { if (status === 'approved') {
return 'Approved'; return 'Active';
} }
if (status === 'rejected') { if (status === 'rejected') {
return 'Rejected'; return 'Rejected';
@@ -837,6 +837,7 @@ function App() {
const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false); const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
@@ -864,6 +865,10 @@ function App() {
destinationOwnerEmail: '', destinationOwnerEmail: '',
notes: '', notes: '',
}); });
const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '',
targetWorkspaceId: '',
});
const [deletingBird, setDeletingBird] = useState(false); const [deletingBird, setDeletingBird] = useState(false);
const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
@@ -874,6 +879,10 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId], [birds, selectedBirdId],
); );
const transferableWorkspaces = useMemo(
() => authSession?.workspaces.filter((entry) => entry.workspace.id !== workspace?.id) ?? [],
[authSession, workspace?.id],
);
const editingBird = useMemo( const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null, () => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId], [birds, editingBirdId],
@@ -2161,6 +2170,53 @@ function App() {
} }
}; };
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetWorkspaceId: Number(flockTransferForm.targetWorkspaceId),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
}
const data = (await readJsonSafely<{ bird?: Bird }>(response)) ?? {};
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
setAllBirdWeights((current) => {
const next = { ...current };
delete next[flockTransferForm.birdId];
return next;
});
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
if (selectedBird?.id === flockTransferForm.birdId) {
setSelectedBirdId('');
}
if (editingBirdId === flockTransferForm.birdId) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
setFlockTransferForm({
birdId: '',
targetWorkspaceId: '',
});
window.alert(`${transferredBirdName} was moved to the selected flock.`);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.');
}
};
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
@@ -2213,6 +2269,47 @@ function App() {
} }
}; };
const handleDeleteWorkspace = async () => {
if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') {
return;
}
const confirmed = window.confirm(
`Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`,
);
if (!confirmed) {
return;
}
setError('');
setDeletingWorkspace(true);
try {
const response = await apiFetch('/workspace', authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete flock.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Flock was deleted but the session could not be refreshed.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.');
} finally {
setDeletingWorkspace(false);
}
};
const handleCancelRescueRequest = async () => { const handleCancelRescueRequest = async () => {
if (!authToken) { if (!authToken) {
return; return;
@@ -3226,6 +3323,14 @@ function App() {
? 'Convert current flock to rescue' ? 'Convert current flock to rescue'
: 'Save flock settings'} : 'Save flock settings'}
</button> </button>
{activeMembership?.role === 'owner' ? (
<button className="danger-button" onClick={handleDeleteWorkspace} type="button" disabled={deletingWorkspace}>
{deletingWorkspace ? 'Deleting flock...' : 'Delete flock'}
</button>
) : null}
{activeMembership?.role === 'owner' ? (
<small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small>
) : null}
</form> </form>
</article> </article>
@@ -3241,14 +3346,22 @@ function App() {
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong> <strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span> <span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
</article> </article>
<article className="summary-card"> {workspace?.workspaceType !== 'rescue' ? (
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong> <article className="summary-card">
<span>Flock write access will follow subscription health once billing is connected.</span> <strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
</article> <span>Flock write access will follow subscription health once billing is connected.</span>
</article>
) : null}
{workspace?.workspaceType === 'rescue' ? ( {workspace?.workspaceType === 'rescue' ? (
<article className="summary-card"> <article className="summary-card">
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong> <strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
<span>Rescue flocks are read-only until an admin approves their verification.</span> <span>
{workspace.rescueVerificationStatus === 'approved'
? 'Rescue verification is approved and this flock is fully active.'
: workspace.rescueVerificationStatus === 'rejected'
? 'This rescue request was rejected. Update the flock or contact support before trying again.'
: 'Rescue flocks are read-only until an admin approves their verification.'}
</span>
{workspace.rescueVerificationStatus === 'pending' && {workspace.rescueVerificationStatus === 'pending' &&
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? ( (activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
<button <button
@@ -3858,12 +3971,56 @@ function App() {
{expandedSettingsSection === 'transfer' ? ( {expandedSettingsSection === 'transfer' ? (
<> <>
<p className="muted"> <p className="muted">
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would Move a bird to another flock you already belong to. This keeps the bird record, weight history, and vet visits attached while
later use to safely move a bird record between accounts. changing which flock owns it.
</p>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
<select
value={flockTransferForm.birdId}
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value })}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} Band {bird.tagId}
</option>
))}
</select>
</label>
<label>
Destination flock
<select
value={flockTransferForm.targetWorkspaceId}
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, targetWorkspaceId: event.target.value })}
required
>
<option value="">Select another flock you can access</option>
{transferableWorkspaces.map((entry) => (
<option key={entry.workspace.id} value={String(entry.workspace.id)}>
{entry.workspace.name} {formatWorkspaceRole(entry.membership.role)}
</option>
))}
</select>
</label>
<button className="primary-button" type="submit" disabled={!transferableWorkspaces.length}>
Move bird to another flock
</button>
{!transferableWorkspaces.length ? (
<small className="muted">Create or join another flock first to use in-app bird transfers.</small>
) : null}
</form>
<div className="form-divider" />
<p className="muted">
For future owner-to-owner handoffs outside your own flocks, save a transfer draft below.
</p> </p>
<form className="form-panel" onSubmit={handleMergeSubmit}> <form className="form-panel" onSubmit={handleMergeSubmit}>
<label> <label>
Bird to transfer Bird for external transfer prep
<select <select
value={mergeForm.birdId} value={mergeForm.birdId}
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })} onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
+6
View File
@@ -815,6 +815,12 @@ textarea {
transform: translateY(-1px); transform: translateY(-1px);
} }
.form-divider {
height: 1px;
width: 100%;
background: linear-gradient(90deg, transparent, rgba(53, 129, 98, 0.32), transparent);
}
.inline-form { .inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }