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 (never main). Verify gate: npx tsc --noEmit and npm 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 gql constants with co-located TS interfaces in src/graphql/<feature>.ts (mirror src/graphql/public.ts).
  • The live API schema (api.plings.io): invitationPreview(token: String!): InvitationPreview with fields organizationName organizationName! inviterEmail role status language expiresAt — statuses pending/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, acceptInvitation as 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: Verifynpx tsc --noEmit clean and npm run build succeeds.

  • 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 in docs/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”):
    1. 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.
    2. 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.
    3. 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.
    4. 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 (Flow Doc 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 --noEmit still 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.md currently 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 several owner-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 spec docs/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 Table and ### ownership_transfers Table sections in the file’s established style (sql block of the real CREATE TABLE from Plings-API/migrations/2026-07-15-*.sql, with -- ✅ VERIFIED annotations), 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 keeps Organization. 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 multiple owner-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.