Add organization member list

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-14 09:38:01 +01:00
parent b3751636b2
commit 87f7654b10
17 changed files with 1401 additions and 344 deletions

View File

@@ -22,16 +22,18 @@ import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { buildEndpoint } from "@/utils"; import { buildEndpoint } from "@/utils";
export const navUserFragment = graphql` export const navUserFragment = graphql`
fragment NavUser_viewer on User { fragment NavUser_viewer on Viewer {
id user {
fullName id
email fullName
email
}
} }
`; `;
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) { export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer); const currentUser = useFragment(navUserFragment, viewer).user;
const handleLogout = async () => { const handleLogout = async () => {
fetch(buildEndpoint("/api/console/v1/auth/logout"), { fetch(buildEndpoint("/api/console/v1/auth/logout"), {

View File

@@ -27,7 +27,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const organizationSwitcherFragment = graphql` export const organizationSwitcherFragment = graphql`
fragment OrganizationSwitcher_organizations on User { fragment OrganizationSwitcher_organizations on Viewer {
organizations(first: 25) organizations(first: 25)
@connection(key: "OrganizationSwitcher_organizations") { @connection(key: "OrganizationSwitcher_organizations") {
__id __id

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<d4b79299641fcf7b2547eec4064af57a>> * @generated SignedSource<<72d01b98680bc82c2d5bdc8c2fa2d49d>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -47,7 +47,7 @@ return {
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "User", "concreteType": "Viewer",
"kind": "LinkedField", "kind": "LinkedField",
"name": "viewer", "name": "viewer",
"plural": false, "plural": false,
@@ -79,7 +79,7 @@ return {
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "User", "concreteType": "Viewer",
"kind": "LinkedField", "kind": "LinkedField",
"name": "viewer", "name": "viewer",
"plural": false, "plural": false,
@@ -196,15 +196,27 @@ return {
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "concreteType": "User",
"name": "fullName", "kind": "LinkedField",
"storageKey": null "name": "user",
}, "plural": false,
{ "selections": [
"alias": null, (v0/*: any*/),
"args": null, {
"kind": "ScalarField", "alias": null,
"name": "email", "args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null "storageKey": null
} }
], ],
@@ -213,12 +225,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "d92e5e742128d82b461ab1b6d52cd2a6", "cacheID": "51c72d5d6b9e1d4e5e7f45e7280a0534",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "AppSidebarQuery", "name": "AppSidebarQuery",
"operationKind": "query", "operationKind": "query",
"text": "query AppSidebarQuery {\n viewer {\n id\n ...OrganizationSwitcher_organizations\n ...NavUser_viewer\n }\n}\n\nfragment NavUser_viewer on User {\n id\n fullName\n email\n}\n\nfragment OrganizationSwitcher_organizations on User {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n" "text": "query AppSidebarQuery {\n viewer {\n id\n ...OrganizationSwitcher_organizations\n ...NavUser_viewer\n }\n}\n\nfragment NavUser_viewer on Viewer {\n user {\n id\n fullName\n email\n }\n}\n\nfragment OrganizationSwitcher_organizations on Viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
} }
}; };
})(); })();

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<0965f25c8dedb59a9316e4df4d773af6>> * @generated SignedSource<<0d5ada0f912fcf3a8f2f3328b0943b28>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,9 +11,11 @@
import { ReaderFragment } from 'relay-runtime'; import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type NavUser_viewer$data = { export type NavUser_viewer$data = {
readonly email: string; readonly user: {
readonly fullName: string; readonly email: string;
readonly id: string; readonly fullName: string;
readonly id: string;
};
readonly " $fragmentType": "NavUser_viewer"; readonly " $fragmentType": "NavUser_viewer";
}; };
export type NavUser_viewer$key = { export type NavUser_viewer$key = {
@@ -30,29 +32,40 @@ const node: ReaderFragment = {
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "concreteType": "User",
"name": "id", "kind": "LinkedField",
"storageKey": null "name": "user",
}, "plural": false,
{ "selections": [
"alias": null, {
"args": null, "alias": null,
"kind": "ScalarField", "args": null,
"name": "fullName", "kind": "ScalarField",
"storageKey": null "name": "id",
}, "storageKey": null
{ },
"alias": null, {
"args": null, "alias": null,
"kind": "ScalarField", "args": null,
"name": "email", "kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null "storageKey": null
} }
], ],
"type": "User", "type": "Viewer",
"abstractKey": null "abstractKey": null
}; };
(node as any).hash = "7ee85a272f543b514c5a6a3148fd1613"; (node as any).hash = "05e26e238976738b4cff26f37c5f29e7";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<075aa1caf8416e0cce7536d0da9b5ca9>> * @generated SignedSource<<152991faff70c7ef6c4751fac48090be>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -151,10 +151,10 @@ const node: ReaderFragment = {
"storageKey": null "storageKey": null
} }
], ],
"type": "User", "type": "Viewer",
"abstractKey": null "abstractKey": null
}; };
(node as any).hash = "8150704a009e88c096c6f6aa13cada52"; (node as any).hash = "4bc97175bc64a6c7554a6ec865d12850";
export default node; export default node;

View File

@@ -16,7 +16,6 @@ import { OrganizationSelectionPageQuery } from "./__generated__/OrganizationSele
const organizationSelectionQuery = graphql` const organizationSelectionQuery = graphql`
query OrganizationSelectionPageQuery { query OrganizationSelectionPageQuery {
viewer { viewer {
id
organizations(first: 25) { organizations(first: 25) {
edges { edges {
node { node {
@@ -34,11 +33,11 @@ export default function OrganizationSelectionPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const data = useLazyLoadQuery<OrganizationSelectionPageQuery>( const data = useLazyLoadQuery<OrganizationSelectionPageQuery>(
organizationSelectionQuery, organizationSelectionQuery,
{}, {}
); );
const organizations = data.viewer.organizations.edges.map( const organizations = data.viewer.organizations.edges.map(
(edge) => edge.node, (edge) => edge.node
); );
useEffect(() => { useEffect(() => {

View File

@@ -45,6 +45,16 @@ const settingsPageQuery = graphql`
... on Organization { ... on Organization {
name name
logoUrl logoUrl
users(first: 100) {
edges {
node {
id
fullName
email
createdAt
}
}
}
} }
} }
} }
@@ -64,13 +74,6 @@ const updateOrganizationMutation = graphql`
} }
`; `;
interface Member {
id: string;
fullName: string;
primaryEmailAddress: string;
role: string;
}
function SettingsPageContent({ function SettingsPageContent({
queryRef, queryRef,
}: { }: {
@@ -78,11 +81,14 @@ function SettingsPageContent({
}) { }) {
const data = usePreloadedQuery(settingsPageQuery, queryRef); const data = usePreloadedQuery(settingsPageQuery, queryRef);
const organization = data.organization; const organization = data.organization;
const members: Member[] = []; const users = organization.users?.edges.map((edge) => edge.node) || [];
const { toast } = useToast(); const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isEditNameOpen, setIsEditNameOpen] = useState(false); const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("Member");
const [organizationName, setOrganizationName] = useState( const [organizationName, setOrganizationName] = useState(
organization.name || "" organization.name || ""
); );
@@ -162,6 +168,19 @@ function SettingsPageContent({
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const handleInviteMember = () => {
// This is a placeholder for the actual invite functionality
// In a real implementation, we would use a GraphQL mutation to invite the user
// For now, we just show a toast message and close the dialog
toast({
title: "Invitation sent",
description: `An invitation has been sent to ${inviteEmail}`,
variant: "default",
});
setIsInviteOpen(false);
setInviteEmail("");
};
return ( return (
<> <>
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
@@ -245,56 +264,77 @@ function SettingsPageContent({
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Workspace members</CardTitle> <div>
<CardDescription> <CardTitle>Workspace members</CardTitle>
Manage who has privileged access to your workspace and their <CardDescription>
permissions. Manage who has privileged access to your workspace and their
</CardDescription> permissions.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsInviteOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Invite member
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{members.map((member) => ( {users.length === 0 ? (
<div <div className="flex flex-col items-center justify-center p-8 text-center">
key={member.id} <div className="rounded-full bg-muted p-3">
className="flex items-center justify-between rounded-lg border p-3 shadow-xs" <Building2 className="h-6 w-6 text-muted-foreground" />
> </div>
<div className="flex items-center gap-3"> <h3 className="mt-4 text-lg font-medium">No members found</h3>
<Avatar> <p className="mt-2 text-sm text-muted-foreground">
<AvatarFallback> You haven&apos;t added any members to your workspace yet.
{member.fullName.charAt(0)} </p>
</AvatarFallback> </div>
</Avatar> ) : (
<div className="flex flex-col"> users.map((user) => (
<span className="text-sm font-medium"> <div
{member.fullName} key={user.id}
</span> className="flex items-center justify-between rounded-lg border p-3 shadow-xs"
>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{user.fullName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.fullName}
</span>
<span className="text-sm text-muted-foreground">
{user.email}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{member.primaryEmailAddress} Owner
</span> </span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-red-600">
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> ))
<span className="text-sm text-muted-foreground"> )}
{member.role}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Change role</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -327,6 +367,48 @@ function SettingsPageContent({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your workspace.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<select
id="role"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
>
<option value="Admin">Admin</option>
<option value="Member">Member</option>
<option value="Viewer">Viewer</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsInviteOpen(false)}>
Cancel
</Button>
<Button onClick={handleInviteMember}>Send Invitation</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<91e1415ad0a7d8ee0ae2a8e212f42f19>> * @generated SignedSource<<371bd1a7433b281f20dd8925915a7ff2>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -25,7 +25,7 @@ var v0 = [
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "User", "concreteType": "Viewer",
"kind": "LinkedField", "kind": "LinkedField",
"name": "viewer", "name": "viewer",
"plural": false, "plural": false,

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<d50c4986386f83d9755379cd44eb7c2c>> * @generated SignedSource<<282a94b76fe5e17bd40b45ced6445ef3>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -12,7 +12,6 @@ import { ConcreteRequest } from 'relay-runtime';
export type OrganizationSelectionPageQuery$variables = Record<PropertyKey, never>; export type OrganizationSelectionPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationSelectionPageQuery$data = { export type OrganizationSelectionPageQuery$data = {
readonly viewer: { readonly viewer: {
readonly id: string;
readonly organizations: { readonly organizations: {
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
@@ -37,81 +36,80 @@ var v0 = {
"name": "id", "name": "id",
"storageKey": null "storageKey": null
}, },
v1 = [ v1 = {
{ "alias": null,
"alias": null, "args": [
"args": null, {
"concreteType": "User", "kind": "Literal",
"kind": "LinkedField", "name": "first",
"name": "viewer", "value": 25
"plural": false, }
"selections": [ ],
(v0/*: any*/), "concreteType": "OrganizationConnection",
{ "kind": "LinkedField",
"alias": null, "name": "organizations",
"args": [ "plural": false,
{ "selections": [
"kind": "Literal", {
"name": "first", "alias": null,
"value": 25 "args": null,
} "concreteType": "OrganizationEdge",
], "kind": "LinkedField",
"concreteType": "OrganizationConnection", "name": "edges",
"kind": "LinkedField", "plural": true,
"name": "organizations", "selections": [
"plural": false, {
"selections": [ "alias": null,
{ "args": null,
"alias": null, "concreteType": "Organization",
"args": null, "kind": "LinkedField",
"concreteType": "OrganizationEdge", "name": "node",
"kind": "LinkedField", "plural": false,
"name": "edges", "selections": [
"plural": true, (v0/*: any*/),
"selections": [ {
{ "alias": null,
"alias": null, "args": null,
"args": null, "kind": "ScalarField",
"concreteType": "Organization", "name": "name",
"kind": "LinkedField", "storageKey": null
"name": "node", },
"plural": false, {
"selections": [ "alias": null,
(v0/*: any*/), "args": null,
{ "kind": "ScalarField",
"alias": null, "name": "logoUrl",
"args": null, "storageKey": null
"kind": "ScalarField", }
"name": "name", ],
"storageKey": null "storageKey": null
}, }
{ ],
"alias": null, "storageKey": null
"args": null, }
"kind": "ScalarField", ],
"name": "logoUrl", "storageKey": "organizations(first:25)"
"storageKey": null };
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "organizations(first:25)"
}
],
"storageKey": null
}
];
return { return {
"fragment": { "fragment": {
"argumentDefinitions": [], "argumentDefinitions": [],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "OrganizationSelectionPageQuery", "name": "OrganizationSelectionPageQuery",
"selections": (v1/*: any*/), "selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/)
],
"storageKey": null
}
],
"type": "Query", "type": "Query",
"abstractKey": null "abstractKey": null
}, },
@@ -120,19 +118,33 @@ return {
"argumentDefinitions": [], "argumentDefinitions": [],
"kind": "Operation", "kind": "Operation",
"name": "OrganizationSelectionPageQuery", "name": "OrganizationSelectionPageQuery",
"selections": (v1/*: any*/) "selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/),
(v0/*: any*/)
],
"storageKey": null
}
]
}, },
"params": { "params": {
"cacheID": "634e8f413a8b1f2685cfc1f4fd3c13cc", "cacheID": "2938cb693a84c22366862de424a84d4b",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "OrganizationSelectionPageQuery", "name": "OrganizationSelectionPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query OrganizationSelectionPageQuery {\n viewer {\n id\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n }\n}\n" "text": "query OrganizationSelectionPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n id\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "786037ac4c8d61f3b7cf6b4536aadbbb"; (node as any).hash = "c41774fd198ab5f0f4b866d805099baf";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<eb02c3812f7904bcf7d937d1c29d9905>> * @generated SignedSource<<7fcee268b2974653bc3734b49b4082a4>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -17,6 +17,16 @@ export type SettingsPageQuery$data = {
readonly id: string; readonly id: string;
readonly logoUrl?: string | null | undefined; readonly logoUrl?: string | null | undefined;
readonly name?: string; readonly name?: string;
readonly users?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: string;
readonly email: string;
readonly fullName: string;
readonly id: string;
};
}>;
};
}; };
}; };
export type SettingsPageQuery = { export type SettingsPageQuery = {
@@ -62,6 +72,67 @@ v3 = {
"kind": "ScalarField", "kind": "ScalarField",
"name": "logoUrl", "name": "logoUrl",
"storageKey": null "storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"concreteType": "UserConnection",
"kind": "LinkedField",
"name": "users",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "UserEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "users(first:100)"
} }
], ],
"type": "Organization", "type": "Organization",
@@ -120,16 +191,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "1ff42392cdd403c92101dc9c5fb7ceda", "cacheID": "de417aa8583ad2d1cc7ef18223a4dc2d",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "SettingsPageQuery", "name": "SettingsPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query SettingsPageQuery(\n $organizationID: ID!\n) {\n organization: node(id: $organizationID) {\n __typename\n id\n ... on Organization {\n name\n logoUrl\n }\n }\n}\n" "text": "query SettingsPageQuery(\n $organizationID: ID!\n) {\n organization: node(id: $organizationID) {\n __typename\n id\n ... on Organization {\n name\n logoUrl\n users(first: 100) {\n edges {\n node {\n id\n fullName\n email\n createdAt\n }\n }\n }\n }\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "60cdb8a35f7e5f58536101243b57e57c"; (node as any).hash = "1d9482689dd9f305ca7a45fdf4ab088c";
export default node; export default node;

View File

@@ -18,6 +18,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"maps"
"strings" "strings"
"time" "time"
@@ -39,6 +40,8 @@ type (
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
Users []*User
ErrUserNotFound struct { ErrUserNotFound struct {
Identifier string Identifier string
} }
@@ -60,6 +63,50 @@ func (u User) CursorKey() page.CursorKey {
return page.NewCursorKey(u.ID, u.CreatedAt) return page.NewCursorKey(u.ID, u.CreatedAt)
} }
func (u *Users) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
organizationID gid.GID,
cursor *page.Cursor,
) error {
q := `
SELECT
id,
email_address,
hashed_password,
email_address_verified,
fullname,
created_at,
updated_at
FROM
users
WHERE
id IN (
SELECT user_id FROM users_organizations WHERE organization_id = @organization_id
)
AND %s
`
q = fmt.Sprintf(q, cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query users: %w", err)
}
users, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[User])
if err != nil {
return fmt.Errorf("cannot collect users: %w", err)
}
*u = users
return nil
}
func (u *User) LoadByEmail( func (u *User) LoadByEmail(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,

View File

@@ -109,6 +109,13 @@ type Organization implements Node {
name: String! name: String!
logoUrl: String @goField(forceResolver: true) logoUrl: String @goField(forceResolver: true)
users(
first: Int
after: CursorKey
last: Int
before: CursorKey
): UserConnection! @goField(forceResolver: true)
frameworks( frameworks(
first: Int first: Int
after: CursorKey after: CursorKey
@@ -298,18 +305,20 @@ type Evidence implements Node {
updatedAt: Datetime! updatedAt: Datetime!
} }
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
cursor: CursorKey!
node: User!
}
type User implements Node { type User implements Node {
id: ID! id: ID!
fullName: String! fullName: String!
email: String! email: String!
organizations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): OrganizationConnection! @goField(forceResolver: true)
createdAt: Datetime! createdAt: Datetime!
updatedAt: Datetime! updatedAt: Datetime!
} }
@@ -321,7 +330,20 @@ type Session {
type Query { type Query {
node(id: ID!): Node! node(id: ID!): Node!
viewer: User! viewer: Viewer!
}
type Viewer {
id: ID!
user: User!
organizations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): OrganizationConnection! @goField(forceResolver: true)
} }
type Mutation { type Mutation {

File diff suppressed because it is too large Load Diff

View File

@@ -254,6 +254,7 @@ type Organization struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"` LogoURL *string `json:"logoUrl,omitempty"`
Users *UserConnection `json:"users"`
Frameworks *FrameworkConnection `json:"frameworks"` Frameworks *FrameworkConnection `json:"frameworks"`
Vendors *VendorConnection `json:"vendors"` Vendors *VendorConnection `json:"vendors"`
Peoples *PeopleConnection `json:"peoples"` Peoples *PeopleConnection `json:"peoples"`
@@ -477,17 +478,26 @@ type UploadEvidencePayload struct {
} }
type User struct { type User struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
Email string `json:"email"` Email string `json:"email"`
Organizations *OrganizationConnection `json:"organizations"` CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
func (User) IsNode() {} func (User) IsNode() {}
func (this User) GetID() gid.GID { return this.ID } func (this User) GetID() gid.GID { return this.ID }
type UserConnection struct {
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type UserEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *User `json:"node"`
}
type Vendor struct { type Vendor struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -516,3 +526,9 @@ type VendorEdge struct {
Cursor page.CursorKey `json:"cursor"` Cursor page.CursorKey `json:"cursor"`
Node *Vendor `json:"node"` Node *Vendor `json:"node"`
} }
type Viewer struct {
ID gid.GID `json:"id"`
User *User `json:"user"`
Organizations *OrganizationConnection `json:"organizations"`
}

View File

@@ -16,8 +16,28 @@ package types
import ( import (
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
) )
func NewUserConnection(p *page.Page[*coredata.User]) *UserConnection {
var edges = make([]*UserEdge, len(p.Data))
for i := range edges {
edges[i] = NewUserEdge(p.Data[i])
}
return &UserConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewUserEdge(user *coredata.User) *UserEdge {
return &UserEdge{
Node: NewUser(user),
}
}
func NewUser(u *coredata.User) *User { func NewUser(u *coredata.User) *User {
return &User{ return &User{
ID: u.ID, ID: u.ID,

View File

@@ -517,6 +517,18 @@ func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organizat
return svc.Organizations.GenerateLogoURL(ctx, obj.ID, 1*time.Hour) return svc.Organizations.GenerateLogoURL(ctx, obj.ID, 1*time.Hour)
} }
// Users is the resolver for the users field.
func (r *organizationResolver) Users(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.UserConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.usrmgrSvc.ListUsersForTenant(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list users: %w", err)
}
return types.NewUserConnection(page), nil
}
// Frameworks is the resolver for the frameworks field. // Frameworks is the resolver for the frameworks field.
func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) { func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID()) svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())
@@ -657,9 +669,14 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
} }
// Viewer is the resolver for the viewer field. // Viewer is the resolver for the viewer field.
func (r *queryResolver) Viewer(ctx context.Context) (*types.User, error) { func (r *queryResolver) Viewer(ctx context.Context) (*types.Viewer, error) {
user := UserFromContext(ctx) user := UserFromContext(ctx)
return types.NewUser(user), nil session := SessionFromContext(ctx)
return &types.Viewer{
ID: session.ID,
User: types.NewUser(user),
}, nil
} }
// AssignedTo is the resolver for the assignedTo field. // AssignedTo is the resolver for the assignedTo field.
@@ -697,8 +714,10 @@ func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *in
} }
// Organizations is the resolver for the organizations field. // Organizations is the resolver for the organizations field.
func (r *userResolver) Organizations(ctx context.Context, obj *types.User, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.OrganizationConnection, error) { func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.OrganizationConnection, error) {
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, obj.ID) user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err)) panic(fmt.Errorf("failed to list organizations for user: %w", err))
} }
@@ -738,8 +757,8 @@ func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} }
// Task returns schema.TaskResolver implementation. // Task returns schema.TaskResolver implementation.
func (r *Resolver) Task() schema.TaskResolver { return &taskResolver{r} } func (r *Resolver) Task() schema.TaskResolver { return &taskResolver{r} }
// User returns schema.UserResolver implementation. // Viewer returns schema.ViewerResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} } func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }
type controlResolver struct{ *Resolver } type controlResolver struct{ *Resolver }
type evidenceResolver struct{ *Resolver } type evidenceResolver struct{ *Resolver }
@@ -749,4 +768,4 @@ type organizationResolver struct{ *Resolver }
type policyResolver struct{ *Resolver } type policyResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
type taskResolver struct{ *Resolver } type taskResolver struct{ *Resolver }
type userResolver struct{ *Resolver } type viewerResolver struct{ *Resolver }

View File

@@ -25,6 +25,7 @@ import (
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash" "github.com/getprobo/probo/pkg/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken" "github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
) )
@@ -491,3 +492,24 @@ func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
}, },
) )
} }
func (s Service) ListUsersForTenant(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor,
) (*page.Page[*coredata.User], error) {
users := coredata.Users{}
err := s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
return users.LoadByOrganizationID(ctx, tx, organizationID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(users, cursor), nil
}