@@ -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"), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user