Compare commits

...

1 Commits

Author SHA1 Message Date
Guido D'Orsi
72999d8832 feat: migrate to a request to join pattern 2024-12-27 15:59:05 +01:00
7 changed files with 278 additions and 102 deletions

View File

@@ -3,8 +3,8 @@ import { useParams } from "react-router";
import { Layout } from "./Layout.tsx"; import { Layout } from "./Layout.tsx";
import { CreateProject } from "./components/CreateProject.tsx"; import { CreateProject } from "./components/CreateProject.tsx";
import { Heading } from "./components/Heading.tsx"; import { Heading } from "./components/Heading.tsx";
import { InviteLink } from "./components/InviteLink.tsx";
import { OrganizationMembers } from "./components/OrganizationMembers.tsx"; import { OrganizationMembers } from "./components/OrganizationMembers.tsx";
import { RequestJoinButton } from "./components/RequestJoinButton.tsx";
import { useCoState } from "./main.tsx"; import { useCoState } from "./main.tsx";
import { Organization } from "./schema.ts"; import { Organization } from "./schema.ts";
@@ -12,11 +12,23 @@ export function OrganizationPage() {
const paramOrganizationId = useParams<{ organizationId: ID<Organization> }>() const paramOrganizationId = useParams<{ organizationId: ID<Organization> }>()
.organizationId; .organizationId;
const organization = useCoState(Organization, paramOrganizationId, { const organization = useCoState(Organization, paramOrganizationId, {});
projects: [],
});
if (!organization) return <p>Loading organization...</p>; const organizationWithContent = useCoState(
Organization,
paramOrganizationId,
{
content: {
projects: [],
},
},
);
if (!organization) {
return <p>Loading organization...</p>;
}
const readOnlyAccess = organization && !organizationWithContent;
return ( return (
<Layout> <Layout>
@@ -28,44 +40,46 @@ export function OrganizationPage() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2>Members</h2> <h2>Members</h2>
{organization._owner?.myRole() === "admin" && ( {readOnlyAccess && (
<InviteLink organization={organization} /> <RequestJoinButton organizationId={organization.id} />
)} )}
</div> </div>
</div> </div>
<div className="divide-y"> {organizationWithContent && (
<OrganizationMembers organization={organization} /> <div className="divide-y">
</div> <OrganizationMembers organization={organizationWithContent} />
</div>
)}
</div> </div>
<div className="rounded-lg border shadow-sm bg-white dark:bg-stone-925"> {organizationWithContent && (
<div className="border-b px-4 py-5 sm:px-6"> <div className="rounded-lg border shadow-sm bg-white dark:bg-stone-925">
<h2>Projects</h2> <div className="border-b px-4 py-5 sm:px-6">
</div> <h2>Projects</h2>
<div className="divide-y"> </div>
{organization.projects.length > 0 ? ( <div className="divide-y">
organization.projects.map((project) => {organizationWithContent.content.projects.length > 0 ? (
project ? ( organizationWithContent.content.projects.map((project) =>
<strong project ? (
key={project.id} <strong
className="px-4 py-5 sm:px-6 font-medium block" key={project.id}
> className="px-4 py-5 sm:px-6 font-medium block"
{project.name} >
</strong> {project.name}
) : null, </strong>
) ) : null,
) : ( )
<p className="col-span-full text-center px-4 py-8 sm:px-6"> ) : (
You have no projects yet. <p className="col-span-full text-center px-4 py-8 sm:px-6">
</p> You have no projects yet.
)} </p>
<div className="p-4 sm:p-6"> )}
<CreateProject organization={organization} /> <div className="p-4 sm:p-6">
<CreateProject organization={organization} />
</div>
</div> </div>
</div> </div>
</div> )}
<div></div>
</div> </div>
</Layout> </Layout>
); );

View File

@@ -2,7 +2,7 @@ import { Group, ID } from "jazz-tools";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useAccount, useCoState } from "../main.tsx"; import { useAccount, useCoState } from "../main.tsx";
import { DraftOrganization, ListOfProjects, Organization } from "../schema.ts"; import { DraftOrganization, createOrganization } from "../schema.ts";
import { Errors } from "./Errors.tsx"; import { Errors } from "./Errors.tsx";
import { OrganizationForm } from "./OrganizationForm.tsx"; import { OrganizationForm } from "./OrganizationForm.tsx";
@@ -25,16 +25,17 @@ export function CreateOrganization() {
const group = Group.create({ owner: me }); const group = Group.create({ owner: me });
me.root.organizations.push(draft as Organization); const organization = createOrganization(me, draft.name ?? "");
me.root.organizations.push(organization);
me.root.draftOrganization = DraftOrganization.create( me.root.draftOrganization = DraftOrganization.create(
{ {
projects: ListOfProjects.create([], { owner: group }), name: "",
}, },
{ owner: group }, { owner: group },
); );
navigate(`/organizations/${draft.id}`); navigate(`/organizations/${organization.id}`);
}; };
return ( return (

View File

@@ -11,11 +11,14 @@ export function CreateProject({
const onSave = (e: React.FormEvent<HTMLFormElement>) => { const onSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!organization?.projects) return; if (!organization?.content?.projects) return;
if (name.length > 0) { if (name.length > 0) {
const project = Project.create({ name }, { owner: organization._owner }); const project = Project.create(
organization.projects.push(project); { name },
{ owner: organization.content._owner },
);
organization.content.projects.push(project);
setName(""); setName("");
} }
}; };

View File

@@ -1,44 +0,0 @@
import { createInviteLink } from "jazz-react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Organization } from "../schema.ts";
export function InviteLink({ organization }: { organization: Organization }) {
const [inviteLink, setInviteLink] = useState<string>();
let [copyCount, setCopyCount] = useState(0);
let copied = copyCount > 0;
useEffect(() => {
if (organization) {
setInviteLink(createInviteLink(organization, "writer"));
}
}, [organization.id]);
useEffect(() => {
if (copyCount > 0) {
let timeout = setTimeout(() => setCopyCount(0), 1000);
return () => {
clearTimeout(timeout);
};
}
}, [copyCount]);
const copyUrl = () => {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink).then(() => {
setCopyCount((count) => count + 1);
});
}
};
return (
<button
type="button"
className="inline-flex items-center gap-2 text-blue-500 dark:text-blue-400"
onClick={copyUrl}
>
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
Copy invite link
</button>
);
}

View File

@@ -1,11 +1,31 @@
import { Account, Group, ID } from "jazz-tools"; import { Account, Group, ID } from "jazz-tools";
import { useCoState } from "../main.tsx"; import { useCoState } from "../main.tsx";
import { Organization } from "../schema.ts"; import {
JoinOrganizationRequest,
ListOfJoinOrganizationRequests,
Organization,
OrganizationContent,
acceptJoinOrganizationRequest,
} from "../schema.ts";
export function OrganizationMembers({ export function OrganizationMembers({
organization, organization,
}: { organization: Organization }) { }: { organization: Organization }) {
const group = organization._owner.castAs(Group); const privateContent = useCoState(
OrganizationContent,
organization._refs.content.id,
{},
);
const joinRequests = useCoState(
ListOfJoinOrganizationRequests,
organization._refs.joinRequests.id,
[{}],
);
if (!privateContent) return null;
const group = privateContent._owner.castAs(Group);
const myRole = group.myRole();
return ( return (
<> <>
@@ -16,6 +36,10 @@ export function OrganizationMembers({
role={member.role} role={member.role}
/> />
))} ))}
{myRole === "admin" &&
joinRequests?.map((request) => (
<JoinRequest key={request.id} request={request} />
))}
</> </>
); );
} }
@@ -33,3 +57,31 @@ function Member({
</div> </div>
); );
} }
function JoinRequest({
request,
}: {
request: JoinOrganizationRequest;
}) {
const account = useCoState(Account, request._refs.account.id, {
profile: {},
});
function handleAccept() {
acceptJoinOrganizationRequest(request);
}
if (!account?.profile) return;
return (
<div className="px-4 py-4 sm:px-6 flex justify-between items-center">
<strong className="font-medium">{account.profile.name}</strong>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-4 rounded"
onClick={handleAccept}
>
Accept
</button>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { ID } from "jazz-tools";
import { useAccount, useCoState } from "../main.tsx";
import { Organization, requestJoinOrganization } from "../schema.ts";
export function RequestJoinButton({
organizationId,
}: { organizationId: ID<Organization> }) {
const organization = useCoState(Organization, organizationId, {
joinRequests: [{}],
});
const { me } = useAccount();
if (!organization) return null;
const alreadyRequested = organization.joinRequests.some(
(request) => request._refs.account.id === me.id,
);
if (alreadyRequested) {
return (
<button
type="button"
className="inline-flex items-center gap-2 text-blue-500 dark:text-blue-400"
disabled
>
Waiting for approval
</button>
);
}
return (
<button
type="button"
className="inline-flex items-center gap-2 text-blue-500 dark:text-blue-400"
onClick={() => {
requestJoinOrganization(me, organization);
}}
>
Request to join
</button>
);
}

View File

@@ -1,3 +1,4 @@
import { createInviteLink, parseInviteLink } from "jazz-react";
import { Account, CoList, CoMap, Group, co } from "jazz-tools"; import { Account, CoList, CoMap, Group, co } from "jazz-tools";
export class Project extends CoMap { export class Project extends CoMap {
@@ -7,13 +8,19 @@ export class Project extends CoMap {
export class ListOfProjects extends CoList.Of(co.ref(Project)) {} export class ListOfProjects extends CoList.Of(co.ref(Project)) {}
export class Organization extends CoMap { export class Organization extends CoMap {
// everyone is a reader
name = co.string; name = co.string;
content = co.ref(OrganizationContent); // limited access
joinRequests = co.ref(ListOfJoinOrganizationRequests); // writeOnly access
joinRequestsInviteLink = co.string;
}
export class OrganizationContent extends CoMap {
projects = co.ref(ListOfProjects); projects = co.ref(ListOfProjects);
} }
export class DraftOrganization extends CoMap { export class DraftOrganization extends CoMap {
name = co.optional.string; name = co.optional.string;
projects = co.ref(ListOfProjects);
validate() { validate() {
const errors: string[] = []; const errors: string[] = [];
@@ -28,8 +35,24 @@ export class DraftOrganization extends CoMap {
} }
} }
export class JoinOrganizationRequest extends CoMap {
organization = co.ref(Organization);
account = co.ref(Account);
}
export class ListOfOrganizations extends CoList.Of(co.ref(Organization)) {} export class ListOfOrganizations extends CoList.Of(co.ref(Organization)) {}
export class ListOfJoinOrganizationRequests extends CoList.Of(
co.ref(JoinOrganizationRequest),
) {
removeRequest(request: JoinOrganizationRequest) {
const index = this.findIndex((r) => r?.id === request.id);
if (index !== -1) {
this.splice(index, 1);
}
}
}
export class JazzAccountRoot extends CoMap { export class JazzAccountRoot extends CoMap {
organizations = co.ref(ListOfOrganizations); organizations = co.ref(ListOfOrganizations);
draftOrganization = co.ref(DraftOrganization); draftOrganization = co.ref(DraftOrganization);
@@ -45,24 +68,18 @@ export class JazzAccount extends Account {
}; };
const draftOrganization = DraftOrganization.create( const draftOrganization = DraftOrganization.create(
{ {
projects: ListOfProjects.create([], draftOrganizationOwnership), name: "",
}, },
draftOrganizationOwnership, draftOrganizationOwnership,
); );
const initialOrganizationOwnership = {
owner: Group.create({ owner: this }),
};
const organizations = ListOfOrganizations.create( const organizations = ListOfOrganizations.create(
[ [
Organization.create( createOrganization(
{ this,
name: this.profile?.name this.profile?.name
? `${this.profile.name}'s projects` ? `${this.profile.name}'s projects`
: "Your projects", : "Your projects",
projects: ListOfProjects.create([], initialOrganizationOwnership),
},
initialOrganizationOwnership,
), ),
], ],
{ owner: this }, { owner: this },
@@ -78,3 +95,93 @@ export class JazzAccount extends Account {
} }
} }
} }
export function createOrganization(account: Account, name: string) {
const organizationOwnership = {
owner: Group.create({ owner: account }),
};
// We give read only access to everyone so that guests can see the organization name
// and request to join
organizationOwnership.owner.addMember("everyone", "reader");
const joinRequestsGroup = Group.create({ owner: account });
const joinRequests = ListOfJoinOrganizationRequests.create([], {
owner: joinRequestsGroup,
});
// We give write only access to the join requests so that only the organization admins
// can see and manage join requests
const joinRequestsInviteLink = createInviteLink(joinRequests, "writeOnly");
const contentOwnership = {
owner: Group.create({ owner: account }),
};
const projects = ListOfProjects.create([], contentOwnership);
const content = OrganizationContent.create({ projects }, contentOwnership);
const organization = Organization.create(
{ name, joinRequests, joinRequestsInviteLink, content },
organizationOwnership,
);
return organization;
}
export async function acceptJoinOrganizationRequest(
joinRequest: JoinOrganizationRequest,
) {
const result = await joinRequest.ensureLoaded({
organization: {
joinRequests: [],
content: {},
},
account: {},
});
if (!result) return;
const { organization, account } = result;
organization.joinRequests.removeRequest(joinRequest);
const organizationContentGroup = organization.content._owner.castAs(Group);
organizationContentGroup.addMember(account, "writer");
}
export async function requestJoinOrganization(
account: JazzAccount,
organization: Organization,
) {
const parsedLink = parseInviteLink<ListOfJoinOrganizationRequests>(
organization.joinRequestsInviteLink,
);
if (!parsedLink) return;
const joinRequests = await account.acceptInvite(
parsedLink.valueID,
parsedLink.inviteSecret,
ListOfJoinOrganizationRequests,
);
if (!joinRequests) return;
const joinRequestsGroup = joinRequests._owner.castAs(Group);
const joinRequest = JoinOrganizationRequest.create(
{ organization, account },
{ owner: joinRequestsGroup },
);
joinRequests.push(joinRequest);
const result = await account.ensureLoaded({ root: { organizations: [] } });
if (!result) return;
const { root } = result;
root.organizations.push(organization);
}