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";
export const navUserFragment = graphql`
fragment NavUser_viewer on User {
id
fullName
email
fragment NavUser_viewer on Viewer {
user {
id
fullName
email
}
}
`;
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer);
const currentUser = useFragment(navUserFragment, viewer).user;
const handleLogout = async () => {
fetch(buildEndpoint("/api/console/v1/auth/logout"), {

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<d4b79299641fcf7b2547eec4064af57a>>
* @generated SignedSource<<72d01b98680bc82c2d5bdc8c2fa2d49d>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -47,7 +47,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "User",
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -79,7 +79,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "User",
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -196,15 +196,27 @@ return {
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
@@ -213,12 +225,12 @@ return {
]
},
"params": {
"cacheID": "d92e5e742128d82b461ab1b6d52cd2a6",
"cacheID": "51c72d5d6b9e1d4e5e7f45e7280a0534",
"id": null,
"metadata": {},
"name": "AppSidebarQuery",
"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
* @nogrep
*/
@@ -11,9 +11,11 @@
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NavUser_viewer$data = {
readonly email: string;
readonly fullName: string;
readonly id: string;
readonly user: {
readonly email: string;
readonly fullName: string;
readonly id: string;
};
readonly " $fragmentType": "NavUser_viewer";
};
export type NavUser_viewer$key = {
@@ -30,29 +32,40 @@ const node: ReaderFragment = {
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "User",
"type": "Viewer",
"abstractKey": null
};
(node as any).hash = "7ee85a272f543b514c5a6a3148fd1613";
(node as any).hash = "05e26e238976738b4cff26f37c5f29e7";
export default node;

View File

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

View File

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

View File

@@ -45,6 +45,16 @@ const settingsPageQuery = graphql`
... on Organization {
name
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({
queryRef,
}: {
@@ -78,11 +81,14 @@ function SettingsPageContent({
}) {
const data = usePreloadedQuery(settingsPageQuery, queryRef);
const organization = data.organization;
const members: Member[] = [];
const users = organization.users?.edges.map((edge) => edge.node) || [];
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("Member");
const [organizationName, setOrganizationName] = useState(
organization.name || ""
);
@@ -162,6 +168,19 @@ function SettingsPageContent({
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 (
<>
<div className="container mx-auto p-6 space-y-6">
@@ -245,56 +264,77 @@ function SettingsPageContent({
</Card>
<Card>
<CardHeader>
<CardTitle>Workspace members</CardTitle>
<CardDescription>
Manage who has privileged access to your workspace and their
permissions.
</CardDescription>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Workspace members</CardTitle>
<CardDescription>
Manage who has privileged access to your workspace and their
permissions.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsInviteOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Invite member
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{members.map((member) => (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-3 shadow-xs"
>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{member.fullName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{member.fullName}
</span>
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-muted p-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-medium">No members found</h3>
<p className="mt-2 text-sm text-muted-foreground">
You haven&apos;t added any members to your workspace yet.
</p>
</div>
) : (
users.map((user) => (
<div
key={user.id}
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">
{member.primaryEmailAddress}
Owner
</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 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>
</CardContent>
</Card>
@@ -327,6 +367,48 @@ function SettingsPageContent({
</DialogFooter>
</DialogContent>
</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
* @nogrep
*/
@@ -25,7 +25,7 @@ var v0 = [
{
"alias": null,
"args": null,
"concreteType": "User",
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<eb02c3812f7904bcf7d937d1c29d9905>>
* @generated SignedSource<<7fcee268b2974653bc3734b49b4082a4>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -17,6 +17,16 @@ export type SettingsPageQuery$data = {
readonly id: string;
readonly logoUrl?: string | null | undefined;
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 = {
@@ -62,6 +72,67 @@ v3 = {
"kind": "ScalarField",
"name": "logoUrl",
"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",
@@ -120,16 +191,16 @@ return {
]
},
"params": {
"cacheID": "1ff42392cdd403c92101dc9c5fb7ceda",
"cacheID": "de417aa8583ad2d1cc7ef18223a4dc2d",
"id": null,
"metadata": {},
"name": "SettingsPageQuery",
"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;

View File

@@ -18,6 +18,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
@@ -39,6 +40,8 @@ type (
UpdatedAt time.Time `db:"updated_at"`
}
Users []*User
ErrUserNotFound struct {
Identifier string
}
@@ -60,6 +63,50 @@ func (u User) CursorKey() page.CursorKey {
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(
ctx context.Context,
conn pg.Conn,

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -254,6 +254,7 @@ type Organization struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"`
Users *UserConnection `json:"users"`
Frameworks *FrameworkConnection `json:"frameworks"`
Vendors *VendorConnection `json:"vendors"`
Peoples *PeopleConnection `json:"peoples"`
@@ -477,17 +478,26 @@ type UploadEvidencePayload struct {
}
type User struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email string `json:"email"`
Organizations *OrganizationConnection `json:"organizations"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) IsNode() {}
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 {
ID gid.GID `json:"id"`
Name string `json:"name"`
@@ -516,3 +526,9 @@ type VendorEdge struct {
Cursor page.CursorKey `json:"cursor"`
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 (
"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 {
return &User{
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)
}
// 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.
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())
@@ -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.
func (r *queryResolver) Viewer(ctx context.Context) (*types.User, error) {
func (r *queryResolver) Viewer(ctx context.Context) (*types.Viewer, error) {
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.
@@ -697,8 +714,10 @@ func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *in
}
// 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) {
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, obj.ID)
func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.OrganizationConnection, error) {
user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
if err != nil {
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.
func (r *Resolver) Task() schema.TaskResolver { return &taskResolver{r} }
// User returns schema.UserResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
// Viewer returns schema.ViewerResolver implementation.
func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }
type controlResolver struct{ *Resolver }
type evidenceResolver struct{ *Resolver }
@@ -749,4 +768,4 @@ type organizationResolver struct{ *Resolver }
type policyResolver struct{ *Resolver }
type queryResolver 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/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"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
}