login issue
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
@@ -11,7 +12,12 @@ import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useTeam} from '@/features/team/hooks/useTeam';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {useInvitations, useTeam} from '@/features/team/hooks/useTeam';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import {AddMemberDialog} from '@/features/team/components/AddMemberDialog';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
|
||||
/**
|
||||
@@ -26,18 +32,17 @@ import type {TeamMember} from '@/features/team/types/team';
|
||||
* local state and were lost on refresh. A merchant could not tell any of it
|
||||
* from the real thing.
|
||||
*
|
||||
* ── Why this screen is read-only ─────────────────────────────────────────
|
||||
* Accounts are created by INVITATION, and that flow already exists in the
|
||||
* platform's own web app: a manager mints a code, the holder redeems it and
|
||||
* chooses their own password. Adding a second way to create an account here
|
||||
* would be a second place a login can be born, on the one surface where that
|
||||
* matters most. So this lists the team and does not mint anyone.
|
||||
* ── Why the actions are here now ─────────────────────────────────────────
|
||||
* This was read-only, because `PATCH /api/team/{id}`, the invitation endpoints
|
||||
* and `POST /api/team/members` all answered 404 on the deployed platform when
|
||||
* it was built. They do not any more, so the screen does what it always
|
||||
* described: lists who has access and changes it.
|
||||
*
|
||||
* Role changes and deactivation are supported upstream by
|
||||
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
|
||||
* revokes every session that person holds immediately, so it wants a
|
||||
* confirmation step and `409 last_owner` handling, neither of which belongs in
|
||||
* a change whose purpose is removing invented data.
|
||||
* Deactivating revokes every session that person holds IMMEDIATELY — it is not
|
||||
* a flag that takes effect at their next sign-in. The platform also refuses to
|
||||
* remove the last active owner, answering `409 last_owner`; that message is
|
||||
* surfaced verbatim rather than flattened into "something went wrong", because
|
||||
* it names a rule the merchant can act on.
|
||||
*/
|
||||
|
||||
interface TeamRow extends Record<string, unknown> {
|
||||
@@ -113,6 +118,36 @@ const COLUMNS: TableColumn<TeamRow>[] = [
|
||||
|
||||
export function TeamManagement() {
|
||||
const team = useTeam();
|
||||
const invitations = useInvitations();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reset, setReset] = useState<{label: string; value: string} | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function refresh() {
|
||||
team.refetch();
|
||||
invitations.refetch();
|
||||
}
|
||||
|
||||
async function setActive(id: string, active: boolean) {
|
||||
setError(null);
|
||||
const res = await teamRepository.update(id, {active});
|
||||
// `last_owner` is the one failure worth repeating word for word: it names a
|
||||
// rule rather than a fault, and the merchant can act on it.
|
||||
if (!res.ok) setError(res.message ?? 'Could not change that account.');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function resetPassword(id: string, email: string) {
|
||||
setError(null);
|
||||
const res = await teamRepository.resetPassword(id);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'Could not reset that password.');
|
||||
return;
|
||||
}
|
||||
setReset({label: `New password for ${email}`, value: res.data.password});
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
@@ -137,13 +172,100 @@ export function TeamManagement() {
|
||||
summaryKeys={['role', 'status']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
{/* Says where accounts come from, because the button that used to
|
||||
claim to create one has gone. Without this the screen reads as
|
||||
missing a feature rather than pointing at the right place. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
New members are added by invitation, and choose their own password
|
||||
when they redeem it. Roles and access are managed on the platform.
|
||||
</Text>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
{reset ? (
|
||||
<SecretOnce value={reset.value} label={reset.label} />
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="xsm" color="secondary">
|
||||
An invitation lets somebody choose their own password; a created
|
||||
login shows one once. Deactivating signs that person out at once.
|
||||
</Text>
|
||||
<Button size="sm" onClick={() => setAdding(true)} label="Add someone" />
|
||||
</HStack>
|
||||
|
||||
{members.some((m) => !m.active) ? (
|
||||
<VStack gap={2}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Deactivated
|
||||
</Text>
|
||||
{members
|
||||
.filter((m) => !m.active)
|
||||
.map((m) => (
|
||||
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm">{m.name}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void setActive(m.id, true)}
|
||||
label="Restore access"
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<VStack gap={2}>
|
||||
{members
|
||||
.filter((m) => m.active)
|
||||
.map((m) => (
|
||||
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" color="secondary">
|
||||
{m.email}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void resetPassword(m.id, m.email)}
|
||||
label="Reset password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void setActive(m.id, false)}
|
||||
label="Deactivate"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
{invitations.data && invitations.data.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Invited, not yet joined
|
||||
</Text>
|
||||
{invitations.data.map((inv) => (
|
||||
<HStack key={inv.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" color="secondary">
|
||||
{inv.email} · {inv.role}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void teamRepository
|
||||
.revokeInvitation(inv.id)
|
||||
.then(() => invitations.refetch());
|
||||
}}
|
||||
label="Withdraw"
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{adding ? (
|
||||
<AddMemberDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
Reference in New Issue
Block a user