Owner Invitations — Plings-Web Implementation Plan (landing page + docs package)
Owner Invitations — Plings-Web Implementation Plan (landing page + docs package)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Created: Wed 15 Jul 2026
Spec: docs/specs/2026-07-15-owner-invitations-and-transfers-design.md
Goal: The public invitation landing page plings.io/invite/<token> (the spreading moment), plus the documentation package the spec promises: public use-cases, alignment of the official organization docs with the “ägare” reasoning, flow docs, ADRs, SCHEMA-VERIFICATION.
Architecture: One new public route + page following the ObjectLanding.tsx pattern (Apollo useQuery/useMutation, shadcn Card/Button, Swedish copy, early-return states). The API is already live: public invitationPreview(token) query and auth-gated acceptInvitation(token) mutation. Auth reuses the existing AuthModal (no dedicated /login route exists); the page re-renders on useAuth().user change, so no redirect mechanism is needed.
Tech Stack: React 18, react-router v6, Apollo Client, Supabase Auth (useAuth() from src/contexts/AuthContext.tsx), Tailwind + shadcn/ui.
Global Constraints
- Production branch is
dev(nevermain). Verify gate:npx tsc --noEmitandnpm run build(no unit-test runner in this repo). - Public-page copy is Swedish, matching
src/pages/scan/ObjectLanding.tsx; klarspråk — say “ägare”, never “organisation(en)” in user-facing copy. - GraphQL docs live as exported
gqlconstants with co-located TS interfaces insrc/graphql/<feature>.ts(mirrorsrc/graphql/public.ts). - The live API schema (api.plings.io):
invitationPreview(token: String!): InvitationPreviewwith fieldsorganizationName organizationName! inviterEmail role status language expiresAt— statusespending/accepted/revoked/expired;acceptInvitation(token: String!): AcceptInvitationResult!returning{ organization { id name type } role }. - Docs under
docs/publish to docs.plings.io (Jekyll): escape `` with raw-tags in code samples;internal/is NOT published. - Commit per task with explicit paths; never
git add -A.
Task 1: GraphQL module + invite page + route
Files:
- Create:
src/graphql/invitations.ts - Create:
src/pages/invite/InvitePage.tsx - Modify:
src/App.tsx(public route block, next to/o/:oid)
Interfaces:
- Consumes (live API):
invitationPreview,acceptInvitationas in Global Constraints. -
Produces: route
/invite/:token. - Step 1: Write
src/graphql/invitations.ts
import { gql } from '@apollo/client';
export const INVITATION_PREVIEW = gql`
query InvitationPreview($token: String!) {
invitationPreview(token: $token) {
organizationName
inviterEmail
role
status
language
expiresAt
}
}
`;
export const ACCEPT_INVITATION = gql`
mutation AcceptInvitation($token: String!) {
acceptInvitation(token: $token) {
organization {
id
name
type
}
role
}
}
`;
export interface InvitationPreview {
organizationName: string;
inviterEmail: string | null;
role: string;
status: 'pending' | 'accepted' | 'revoked' | 'expired';
language: string;
expiresAt: string | null;
}
export interface AcceptInvitationResult {
acceptInvitation: {
organization: { id: string; name: string; type: string };
role: string;
};
}
- Step 2: Write
src/pages/invite/InvitePage.tsx
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation } from '@apollo/client';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AuthModal } from '@/components/auth/AuthModal';
import { useAuth } from '@/contexts/AuthContext';
import {
INVITATION_PREVIEW,
ACCEPT_INVITATION,
InvitationPreview,
AcceptInvitationResult,
} from '@/graphql/invitations';
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
member: 'Medlem',
};
const CenteredCard = ({ children }: { children: React.ReactNode }) => (
<div className="min-h-screen bg-background flex justify-center p-4">
<Card className="w-full max-w-sm self-center">
<CardContent className="pt-6 space-y-4 text-center">{children}</CardContent>
</Card>
</div>
);
export default function InvitePage() {
const { token } = useParams<{ token: string }>();
const { user } = useAuth();
const [authOpen, setAuthOpen] = useState(false);
const { data, loading, error } = useQuery<{ invitationPreview: InvitationPreview | null }>(
INVITATION_PREVIEW,
{ variables: { token }, skip: !token },
);
const [accept, { data: acceptData, loading: accepting, error: acceptError }] =
useMutation<AcceptInvitationResult>(ACCEPT_INVITATION);
if (loading) return <CenteredCard><p className="text-muted-foreground">Laddar inbjudan…</p></CenteredCard>;
const preview = data?.invitationPreview;
if (error || !token || !preview) {
return (
<CenteredCard>
<h1 className="text-xl font-semibold">Ogiltig inbjudan</h1>
<p className="text-muted-foreground">
Länken känns inte igen. Be den som bjöd in dig att skicka en ny inbjudan.
</p>
</CenteredCard>
);
}
if (acceptData) {
const org = acceptData.acceptInvitation.organization;
return (
<CenteredCard>
<h1 className="text-xl font-semibold">Välkommen! 🎉</h1>
<p>
Du är nu <strong>{ROLE_LABELS[acceptData.acceptInvitation.role] ?? acceptData.acceptInvitation.role}</strong>{' '}
hos ägaren <strong>{org.name}</strong>.
</p>
<p className="text-muted-foreground">
Öppna Plings-appen på din telefon så finns {org.name} i din lista. Under testperioden
får du appen via TestFlight-inbjudan.
</p>
</CenteredCard>
);
}
if (preview.status !== 'pending') {
const msg: Record<string, string> = {
accepted: 'Den här inbjudan är redan använd.',
revoked: 'Den här inbjudan har återkallats.',
expired: 'Den här inbjudan har gått ut.',
};
return (
<CenteredCard>
<h1 className="text-xl font-semibold">Inbjudan kan inte användas</h1>
<p className="text-muted-foreground">
{msg[preview.status]} Be {preview.inviterEmail ?? 'den som bjöd in dig'} skicka en ny.
</p>
</CenteredCard>
);
}
return (
<CenteredCard>
<h1 className="text-xl font-semibold">Du är inbjuden! 👋</h1>
<p>
<strong>{preview.inviterEmail ?? 'En Plings-användare'}</strong> har bjudit in dig som{' '}
<strong>{ROLE_LABELS[preview.role] ?? preview.role}</strong> hos ägaren{' '}
<strong>{preview.organizationName}</strong>.
</p>
<p className="text-sm text-muted-foreground">
Plings håller reda på saker — vad de är, var de är och vems de är. Som{' '}
{(ROLE_LABELS[preview.role] ?? preview.role).toLowerCase()} ser du och hanterar du
allt som {preview.organizationName} äger.
</p>
{user ? (
<>
<Button className="w-full" disabled={accepting} onClick={() => accept({ variables: { token } })}>
{accepting ? 'Accepterar…' : 'Acceptera inbjudan'}
</Button>
{acceptError && (
<p className="text-sm text-destructive">
Något gick fel — försök igen. ({acceptError.message})
</p>
)}
</>
) : (
<>
<Button className="w-full" onClick={() => setAuthOpen(true)}>
Logga in eller skapa konto
</Button>
<p className="text-xs text-muted-foreground">
Skapar du nytt konto med e-postverifiering? Klicka på länken i inbjudningsmailet
igen efter verifieringen, så kan du acceptera.
</p>
<AuthModal isOpen={authOpen} onClose={() => setAuthOpen(false)} />
</>
)}
</CenteredCard>
);
}
NOTE: verify AuthModal’s actual prop names in src/components/auth/AuthModal.tsx before using (isOpen/onClose assumed — adapt to the real signature).
- Step 3: Add the route in
src/App.tsx, in the public block next to<Route path="/o/:oid" element={<ObjectLanding />} />:
<Route path="/invite/:token" element={<InvitePage />} />
with the import import InvitePage from "./pages/invite/InvitePage"; next to the ObjectLanding import.
-
Step 4: Verify —
npx tsc --noEmitclean andnpm run buildsucceeds. -
Step 5: Commit
git add src/graphql/invitations.ts src/pages/invite/InvitePage.tsx src/App.tsx
git commit -m "feat(web): public invitation landing /invite/:token (preview + accept)"
Task 2: Use-case + flow docs (the named flow ships its pair)
Files:
- Create:
docs/use-cases/owner-invitations.md - Create:
docs/flows/bjud-in-anvandare.md - Create:
docs/flows/byt-agare-overlamning.md - Modify:
docs/flows/README.md(two index rows)
Requirements (write in the repo’s established styles — read one example of each first):
docs/use-cases/owner-invitations.md: check existing UC numbers indocs/use-cases/(grep^# UC-) and take the next free number in a sensible series. Style:# UC-NNN: Ägare, inbjudningar & överlämningar+## Intent (confirmed by Paul, 2026-07-15). Content — the four intent scenarios from the spec, written for USERS (plain Swedish, klarspråk, “ägare” never “organisation”):- Göran-scenariot (spreading): Paul creates the owner “Göran Hamberg”, puts Göran’s tool on it, invites Göran by email; Göran clicks the link, creates an account, accepts — and owns his things in Plings. Goal: every invitation is a spreading moment.
- Gemensamt ägande: Saltsjön is owned by “Paul & Elisabeth” — an owner with two owner-members. Rule: an object has exactly ONE owner; shared ownership = a shared owner with several members.
- Ge bort med innehåll: “ge bort lådan med sakerna i” — preview of the branch, deselect items that shouldn’t follow, recipient accepts (unless you have rights in the receiving owner — then it completes immediately). Reused later for sales.
- Företag: Intelliray AB with several owner-members; invited colleagues get access to everything the company owns.
docs/flows/bjud-in-anvandare.md: LIVING flow doc (how it works NOW): invite (API mutation, owner-gated, rate-limited) → Resend email (sv, i18n-ready) →plings.io/invite/<token>landing (public preview, login/signup, accept) → membership. Token-is-the-key + protections. Current UI status: web landing live; iOS member view/invite UI in progress (update this doc when iOS lands). Link the spec, the UC, and ADR numbers from Task 4.docs/flows/byt-agare-overlamning.md: LIVING flow doc: single-object “Byt ägare” (existing iOS) + transfer-with-contents (API live: initiate/accept/decline/revoke, frozen list, auto-complete rule; iOS UI in progress). NORMAL/CURRENT preview semantics; archived transfers = receipt history for future sale/NFT gate.-
docs/flows/README.md: add two rows to the index table following its exact format (FlowDoc Use-case Key decisions), referencing the new UC and ADRs. -
Jekyll front-matter where the directory’s files use it (flows use
layout: default); escape any ``. - Verify:
npx tsc --noEmitstill clean (docs-only, but keep the gate); commit:
git add docs/use-cases/owner-invitations.md docs/flows/bjud-in-anvandare.md docs/flows/byt-agare-overlamning.md docs/flows/README.md
git commit -m "docs: use-case + flow docs for owner invitations & transfers (ADR-0046 pair rule)"
Task 3: Align the official organization docs with the “ägare” reasoning
Files:
- Modify:
docs/use-cases/personal-organization.md - Modify:
docs/core-systems/organization-ownership-intelligence.md - Modify:
docs/database/SCHEMA-VERIFICATION.md
Requirements:
personal-organization.mdcurrently describes a legacy “Family Account” model with per-member permission levels (View/Update/Manage/Admin) — that model was never built. Rewrite the membership/roles sections to today’s truth: an organization (publikt ord: “ägare”) is a person, family, company or loose constellation (no legal entity required); an object has exactly one owner; shared ownership = shared owner with severalowner-members; roles today are owner/member (admin reserved); members join via email invitations (link to the new UC + flow doc). Keep the parts that are still true; mark fine-grained permissions as future (points to the visibility specdocs/specs/2026-07-15-owner-relations-visibility-design.md).organization-ownership-intelligence.md: add a short dated section (“Relationship to memberships & invitations (2026-07-15)”) clarifying that the inference system predicts WHICH of the user’s owners should own a new object, while membership itself is explicit (invitations →organization_members); link the UC/flow doc. Do not rewrite the algorithm description.-
SCHEMA-VERIFICATION.md: add### organization_invitations Tableand### ownership_transfers Tablesections in the file’s established style (sql block of the real CREATE TABLE fromPlings-API/migrations/2026-07-15-*.sql, with-- ✅ VERIFIEDannotations), and add an**Updated**: Wed 15 Jul 2026 — organization_invitations + ownership_transfers created (applied to live DB via MCP; smoke-verified)line in the header block. Add the timestamp header convention (date + time) used by the repo. - Commit:
git add docs/use-cases/personal-organization.md docs/core-systems/organization-ownership-intelligence.md docs/database/SCHEMA-VERIFICATION.md
git commit -m "docs: align organization docs with the ägare model; SCHEMA-VERIFICATION for new tables"
Task 4: ADRs for the locked decisions
Files:
- Create:
internal/decisions/ADR-0077-agare-is-the-public-word-for-organization.md - Create:
internal/decisions/ADR-0078-one-owner-per-object-shared-ownership-is-a-shared-org.md - Create:
internal/decisions/ADR-0079-invitation-claims-token-is-the-key.md - Create:
internal/decisions/ADR-0080-ownership-transfer-is-a-gated-transaction.md - Modify:
internal/decisions/INDEX.md(4 rows + bump_Next ADR number: 0081._)
Read internal/decisions/README.md first for the exact format (Nygard Context/Decision/Consequences/Links; stub vs full). Content to write (adapt format, keep substance):
- ADR-0077 (stub): UI says “ägare” wherever the system says
Organization(klarspråk, like “mall” for ObjectClass). Schema keepsOrganization. Constellations without a legal entity are valid owners; a “billable” distinction becomes a field when the economy lands. Org-recursion (owner as member of owner) deliberately NOT built. Scope [iOS,Web,API]. Links: spec 2026-07-15, UC, ADR-0078. - ADR-0078 (full): An object has exactly ONE owning organization (
owner_organization_id); shared ownership is modeled as a shared org with multipleowner-role members — never as multi-org ownership of one object. Context: Saltsjön (Paul+Elisabeth), Långudden (6 släktingar). Consequences: “skapa gemensam ägare” is the shared-ownership gesture; future economy needs an additive share column on membership (pro rata); relations/visibility (spec 2) attach to orgs. Scope [API,iOS,Web]. Relates 0060, 0066. - ADR-0079 (full): Invitation claims are token-is-the-key: any logged-in account redeeming a valid one-time token becomes a member, regardless of account email (Apple/Google relay emails make address-matching brittle and would add friction at the spreading moment). Protections: sha256 at rest, single-use under FOR UPDATE, 14-day expiry, visible accept history, removable members, owner-gated issuance, rate limit. Public
invitationPreview(token)exposes only what the email contained. Scope [API,Web,iOS]. - ADR-0080 (full): Ownership transfer is a gated transaction with a lifecycle (
ownership_transfers: frozen id list, pending→completed/declined/revoked, archived rows), not a direct write. Gate: recipient-owner accept, auto-complete when the initiator has owner/admin in the receiving org. Reused for sales (kind=’sale’) and future on-chain/NFT confirmation (Solana or Cardano) as ADDITIONAL gate conditions — additive, not a rebuild. Initiate requires owner role in the giving org (disposal is stricter than changeObjectOwner’s member check). Scope [API,iOS]. Relates 0002 (Solana future-only), 0024.
INDEX rows follow the existing table format with scope tags and relates-chains; statuses all Accepted (decisions survived the session they were made in — locked at spec approval 2026-07-15).
- Commit:
git add internal/decisions/ADR-0077-agare-is-the-public-word-for-organization.md internal/decisions/ADR-0078-one-owner-per-object-shared-ownership-is-a-shared-org.md internal/decisions/ADR-0079-invitation-claims-token-is-the-key.md internal/decisions/ADR-0080-ownership-transfer-is-a-gated-transaction.md internal/decisions/INDEX.md
git commit -m "docs(adr): ADR-0077..0080 — ägare term, one-owner-per-object, token-is-the-key, gated transfers"
Task 5: Roadmap + final verify
- Update
ROADMAP.md(repo root): add the Done entry (invite landing + docs package, this PR) and Backlog items: “AuthModal-copy är engelsk på en svensk publik sida (invite) — försvenska/i18n”; “emailRedirectTo vid signup pekar på/— invite-flödet ber användaren klicka om länken; bygg redirect-back när fler flöden behöver det”. npx tsc --noEmit+npm run build— both green.- Commit
ROADMAP.md.