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

View File

@@ -2,7 +2,7 @@ import { Group, ID } from "jazz-tools";
import { useState } from "react";
import { useNavigate } from "react-router";
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 { OrganizationForm } from "./OrganizationForm.tsx";
@@ -25,16 +25,17 @@ export function CreateOrganization() {
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(
{
projects: ListOfProjects.create([], { owner: group }),
name: "",
},
{ owner: group },
);
navigate(`/organizations/${draft.id}`);
navigate(`/organizations/${organization.id}`);
};
return (

View File

@@ -11,11 +11,14 @@ export function CreateProject({
const onSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!organization?.projects) return;
if (!organization?.content?.projects) return;
if (name.length > 0) {
const project = Project.create({ name }, { owner: organization._owner });
organization.projects.push(project);
const project = Project.create(
{ name },
{ owner: organization.content._owner },
);
organization.content.projects.push(project);
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 { useCoState } from "../main.tsx";
import { Organization } from "../schema.ts";
import {
JoinOrganizationRequest,
ListOfJoinOrganizationRequests,
Organization,
OrganizationContent,
acceptJoinOrganizationRequest,
} from "../schema.ts";
export function OrganizationMembers({
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 (
<>
@@ -16,6 +36,10 @@ export function OrganizationMembers({
role={member.role}
/>
))}
{myRole === "admin" &&
joinRequests?.map((request) => (
<JoinRequest key={request.id} request={request} />
))}
</>
);
}
@@ -33,3 +57,31 @@ function Member({
</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";
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 Organization extends CoMap {
// everyone is a reader
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);
}
export class DraftOrganization extends CoMap {
name = co.optional.string;
projects = co.ref(ListOfProjects);
validate() {
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 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 {
organizations = co.ref(ListOfOrganizations);
draftOrganization = co.ref(DraftOrganization);
@@ -45,24 +68,18 @@ export class JazzAccount extends Account {
};
const draftOrganization = DraftOrganization.create(
{
projects: ListOfProjects.create([], draftOrganizationOwnership),
name: "",
},
draftOrganizationOwnership,
);
const initialOrganizationOwnership = {
owner: Group.create({ owner: this }),
};
const organizations = ListOfOrganizations.create(
[
Organization.create(
{
name: this.profile?.name
? `${this.profile.name}'s projects`
: "Your projects",
projects: ListOfProjects.create([], initialOrganizationOwnership),
},
initialOrganizationOwnership,
createOrganization(
this,
this.profile?.name
? `${this.profile.name}'s projects`
: "Your projects",
),
],
{ 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);
}