Add risks

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-20 18:49:47 +02:00
committed by Sacha Al Himdani
parent a2d1310780
commit a86181c83d
41 changed files with 4533 additions and 1304 deletions

View File

@@ -60,7 +60,7 @@ help: ## Show this help
.PHONY:dev
dev: ## Start the development server
parallel -j 2 --line-buffer ::: "gow -r=false run cmd/probod/main.go" "cd apps/console && npm run dev"
parallel -j 2 --line-buffer ::: "gow -r=false run cmd/probod/main.go" "cd apps/console2 && npm run dev"
.PHONY: fmt
fmt: fmt-go ## Format Go code

View File

@@ -11,8 +11,10 @@
"preview": "vite preview"
},
"dependencies": {
"@probo/hooks": "1.0.0",
"@probo/i18n": "1.0.0",
"@probo/ui": "1.0.0",
"@radix-ui/react-dialog": "^1.1.14",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",

View File

@@ -1,7 +1,12 @@
import { ErrorBoundary } from "react-error-boundary";
import { RouterProvider } from "react-router";
import { routes } from "./routes";
import { router } from "./routes";
import { Toasts } from "@probo/ui";
export function App() {
return <RouterProvider router={routes} />;
return (
<>
<RouterProvider router={router} />
<Toasts />
</>
);
}

View File

@@ -1,4 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
@import "tailwindcss";
@import "@probo/ui/src/theme.css";
@source '../../../packages/ui/src';
@import "tw-animate-css";
@source "../../../packages/ui/src";

View File

@@ -1,4 +1,4 @@
import { Outlet, useParams } from "react-router";
import { Link, Navigate, Outlet, useParams } from "react-router";
import {
DropdownSeparator,
IconArrowBoxLeft,
@@ -13,13 +13,21 @@ import {
IconTodo,
Layout,
SidebarItem,
UserDropdown,
UserDropdown as UserDropdownRoot,
UserDropdownItem,
Skeleton,
Dropdown,
Button,
DropdownItem,
IconChevronGrabberVertical,
IconPlusLarge,
Avatar,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
import { Suspense } from "react";
const MainLayoutQuery = graphql`
query MainLayoutQuery {
@@ -29,42 +37,45 @@ const MainLayoutQuery = graphql`
fullName
email
}
organizations(first: 25) @connection(key: "MainLayout_organizations") {
__id
edges {
node {
id
name
logoUrl
}
}
}
}
}
`;
/**
* Site layout with a header and a sidebar
*/
export function MainLayout() {
const { organizationId } = useParams();
const { __ } = useTranslate();
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {}).viewer
.user;
const prefix = `/organizations/${organizationId}`;
if (!organizationId) {
return <Navigate to="/" />;
}
return (
<Layout
header={
<>
<div className="mr-auto"></div>
<UserDropdown fullName={user.fullName} email={user.email}>
<UserDropdownItem
to="/settings"
icon={IconSettingsGear2}
label={__("Settings")}
/>
<UserDropdownItem
to="/settings"
icon={IconCircleQuestionmark}
label={__("Help")}
/>
<DropdownSeparator />
<UserDropdownItem
variant="danger"
to="/logout"
icon={IconArrowBoxLeft}
label="Logout"
/>
</UserDropdown>
<div className="mr-auto">
<Suspense fallback={<Skeleton className="w-20 h-8" />}>
<OrganizationSelector organizationId={organizationId} />
</Suspense>
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<UserDropdown />
</Suspense>
</>
}
sidebar={
@@ -116,3 +127,75 @@ export function MainLayout() {
</Layout>
);
}
function UserDropdown() {
const { __ } = useTranslate();
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {}).viewer
.user;
return (
<UserDropdownRoot fullName={user.fullName} email={user.email}>
<UserDropdownItem
to="/settings"
icon={IconSettingsGear2}
label={__("Settings")}
/>
<UserDropdownItem
to="/settings"
icon={IconCircleQuestionmark}
label={__("Help")}
/>
<DropdownSeparator />
<UserDropdownItem
variant="danger"
to="/logout"
icon={IconArrowBoxLeft}
label="Logout"
/>
</UserDropdownRoot>
);
}
function OrganizationSelector({ organizationId }: { organizationId: string }) {
const organizations = useLazyLoadQuery<MainLayoutQueryType>(
MainLayoutQuery,
{}
).viewer.organizations.edges.map((edge) => edge.node);
const currentOrganization = organizations.find(
(organization) => organization.id === organizationId
);
const { __ } = useTranslate();
return (
<Dropdown
toggle={
<Button
className="-ml-3"
variant="tertiary"
iconAfter={IconChevronGrabberVertical}
>
{currentOrganization?.name}
</Button>
}
>
{organizations.map((organization) => (
<DropdownItem
asChild
key={organization.id}
icon={IconCircleQuestionmark}
>
<Link to={`/organizations/${organization.id}`}>
<Avatar src={organization.logoUrl} name={organization.name} />
{organization.name}
</Link>
</DropdownItem>
))}
<DropdownSeparator />
<DropdownItem asChild icon={IconPlusLarge}>
<Link to="/organizations/new">
<IconPlusLarge size={16} />
{__("Add organization")}
</Link>
</DropdownItem>
</Dropdown>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<998d88d8fcb28932ec470d6afdb49b8e>>
* @generated SignedSource<<d411922830ba632f8c48c98caa435a22>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,6 +13,16 @@ export type MainLayoutQuery$variables = Record<PropertyKey, never>;
export type MainLayoutQuery$data = {
readonly viewer: {
readonly id: string;
readonly organizations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
}>;
};
readonly user: {
readonly email: string;
readonly fullName: string;
@@ -45,7 +55,104 @@ v2 = {
"kind": "ScalarField",
"name": "email",
"storageKey": null
};
},
v3 = [
{
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 25
}
];
return {
"fragment": {
"argumentDefinitions": [],
@@ -74,6 +181,16 @@ return {
(v2/*: any*/)
],
"storageKey": null
},
{
"alias": "organizations",
"args": null,
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "__MainLayout_organizations_connection",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": null
}
],
"storageKey": null
@@ -110,6 +227,25 @@ return {
(v0/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": (v3/*: any*/),
"storageKey": "organizations(first:25)"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": null,
"handle": "connection",
"key": "MainLayout_organizations",
"kind": "LinkedHandle",
"name": "organizations"
}
],
"storageKey": null
@@ -117,16 +253,28 @@ return {
]
},
"params": {
"cacheID": "4b5a08e03bcb26d2edadb57cd94aa7d1",
"cacheID": "9ab80df970b8fa7c4eabff75a6730ec5",
"id": null,
"metadata": {},
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"viewer",
"organizations"
]
}
]
},
"name": "MainLayoutQuery",
"operationKind": "query",
"text": "query MainLayoutQuery {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n }\n}\n"
"text": "query MainLayoutQuery {\n viewer {\n id\n user {\n fullName\n email\n id\n }\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}\n"
}
};
})();
(node as any).hash = "785f7a3ca19b673a157ef0f649de5c1b";
(node as any).hash = "fc37d1b70f1a239abb4c959d47ee28fd";
export default node;

View File

@@ -0,0 +1,95 @@
import { useTranslate } from "@probo/i18n";
import { useLazyLoadQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { OrganizationSelectionPageQuery as OrganizationSelectionPageQueryType } from "./__generated__/OrganizationSelectionPageQuery.graphql";
import { useEffect } from "react";
import { Link, useNavigate } from "react-router";
import { Avatar, Button, Card, IconPlusLarge } from "@probo/ui";
const OrganizationSelectionPageQuery = graphql`
query OrganizationSelectionPageQuery {
viewer {
organizations(first: 25) {
edges {
node {
id
name
logoUrl
}
}
}
}
}
`;
export default function OrganizationSelectionPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const data = useLazyLoadQuery<OrganizationSelectionPageQueryType>(
OrganizationSelectionPageQuery,
{}
);
const organizations = data.viewer.organizations.edges.map(
(edge) => edge.node
);
// Redirect to the first organization if only one exists
useEffect(() => {
if (organizations.length === 1) {
// navigate(`/organizations/${organizations[0].id}`);
}
}, [organizations]);
return (
<>
<title>{__("Select an organization")}</title>
<div className="space-y-6 w-full">
<h1 className="text-3xl font-bold text-center">
{__("Select an organization")}
</h1>
<div className="space-y-4 w-full">
{organizations.map((organization) => (
<Card
asChild
padded
key={organization.id}
className="w-full hover:bg-tertiary-hover cursor-pointer"
>
<Link
to={`/organizations/${organization.id}`}
className="flex items-center justify-between"
>
<div className="flex items-center gap-4">
<Avatar
src={organization.logoUrl}
name={organization.name}
size="l"
/>
<h2 className="font-semibold text-xl">{organization.name}</h2>
</div>
<Button>{__("Select")}</Button>
</Link>
</Card>
))}
<Card padded>
<h2 className="text-xl font-semibold mb-1">
{__("Create an organization")}
</h2>
<p className="text-txt-tertiary mb-4">
{__("Add a new organization to your account")}
</p>
<Button
to="/organizations/new"
variant="quaternary"
icon={IconPlusLarge}
className="w-full"
>
{__("Create organization")}
</Button>
</Card>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,150 @@
/**
* @generated SignedSource<<282a94b76fe5e17bd40b45ced6445ef3>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type OrganizationSelectionPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationSelectionPageQuery$data = {
readonly viewer: {
readonly organizations: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
}>;
};
};
};
export type OrganizationSelectionPageQuery = {
response: OrganizationSelectionPageQuery$data;
variables: OrganizationSelectionPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"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": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "OrganizationSelectionPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/),
(v0/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "2938cb693a84c22366862de424a84d4b",
"id": null,
"metadata": {},
"name": "OrganizationSelectionPageQuery",
"operationKind": "query",
"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 = "c41774fd198ab5f0f4b866d805099baf";
export default node;

View File

@@ -1,9 +1,9 @@
import { useTranslate } from "@probo/i18n";
import { Button, Input, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { Link, useNavigate } from "react-router";
export function LoginPage() {
export default function LoginPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast();

View File

@@ -0,0 +1,118 @@
import { Button, Card, Input, PageHeader, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useLazyLoadQuery, useMutation } from "react-relay";
import type { NewOrganizationPageQuery as NewOrganizationPageQueryType } from "./__generated__/NewOrganizationPageQuery.graphql";
import type { NewOrganizationPageMutation as NewOrganizationPageMutationType } from "./__generated__/NewOrganizationPageMutation.graphql";
import { useState, type FormEventHandler } from "react";
import { useNavigate } from "react-router";
const createOrganizationMutation = graphql`
mutation NewOrganizationPageMutation(
$input: CreateOrganizationInput!
$connections: [ID!]!
) {
createOrganization(input: $input) {
organizationEdge @appendEdge(connections: $connections) {
node {
id
name
logoUrl
}
}
}
}
`;
const viewerQuery = graphql`
query NewOrganizationPageQuery {
viewer {
id
}
}
`;
export default function NewOrganizationPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast();
const [isFetching, setIsFetching] = useState(false);
const data = useLazyLoadQuery<NewOrganizationPageQueryType>(viewerQuery, {});
const [createOrganization] = useMutation<NewOrganizationPageMutationType>(
createOrganizationMutation
);
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get("name")?.toString()!;
setIsFetching(true);
createOrganization({
variables: {
input: {
name,
},
connections: [
ConnectionHandler.getConnectionID(
data.viewer.id,
"NewOrganizationPageQuery"
),
],
},
onCompleted: (r) => {
setIsFetching(false);
const org = r.createOrganization.organizationEdge.node;
navigate(`/organizations/${org.id}`);
toast({
title: __("Success"),
description: __("Organization has been created successfully"),
variant: "success",
});
},
onError: (e) => {
setIsFetching(false);
toast({
title: __("Error"),
description: e.message ?? __("Failed to create organization"),
variant: "error",
});
},
});
};
return (
<div className="space-y-6">
<PageHeader
title={__("Create Organization")}
description={__(
"Create a new organization to manage your compliance and security needs."
)}
/>
<Card padded asChild>
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-xl font-semibold mb-1">
{__("Organization Details")}
</h2>
<p className="text-txt-tertiary text-sm mb-4">
{__("Enter the basic information about your organization.")}
</p>
<Input
required
name="name"
type="text"
placeholder={__("Organization name")}
label={__("Organization name")}
help={__(
"The name of your organization as it will appear throughout the platform."
)}
/>
<Button disabled={isFetching} type="submit" className="w-full">
{__("Create Organization")}
</Button>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,174 @@
/**
* @generated SignedSource<<b15b08fb2d1a0e4e289a9c3c5b9ec7ef>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateOrganizationInput = {
name: string;
};
export type NewOrganizationPageMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateOrganizationInput;
};
export type NewOrganizationPageMutation$data = {
readonly createOrganization: {
readonly organizationEdge: {
readonly node: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
};
};
};
export type NewOrganizationPageMutation = {
response: NewOrganizationPageMutation$data;
variables: NewOrganizationPageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "organizationEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "NewOrganizationPageMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateOrganizationPayload",
"kind": "LinkedField",
"name": "createOrganization",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "NewOrganizationPageMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateOrganizationPayload",
"kind": "LinkedField",
"name": "createOrganization",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "appendEdge",
"key": "",
"kind": "LinkedHandle",
"name": "organizationEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "917f8c27b3faacda07aa6d62e6123677",
"id": null,
"metadata": {},
"name": "NewOrganizationPageMutation",
"operationKind": "mutation",
"text": "mutation NewOrganizationPageMutation(\n $input: CreateOrganizationInput!\n) {\n createOrganization(input: $input) {\n organizationEdge {\n node {\n id\n name\n logoUrl\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "b88831f98e1414c16a18381ab4aeaa38";
export default node;

View File

@@ -0,0 +1,74 @@
/**
* @generated SignedSource<<83489e54447230c6d654a6baa9fb3e9d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type NewOrganizationPageQuery$variables = Record<PropertyKey, never>;
export type NewOrganizationPageQuery$data = {
readonly viewer: {
readonly id: string;
};
};
export type NewOrganizationPageQuery = {
response: NewOrganizationPageQuery$data;
variables: NewOrganizationPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "NewOrganizationPageQuery",
"selections": (v0/*: any*/),
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "NewOrganizationPageQuery",
"selections": (v0/*: any*/)
},
"params": {
"cacheID": "3a624ea532d0ad916c5e4bc883867f5e",
"id": null,
"metadata": {},
"name": "NewOrganizationPageQuery",
"operationKind": "query",
"text": "query NewOrganizationPageQuery {\n viewer {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "c4d28895f85836c0698352ac737720ad";
export default node;

View File

@@ -0,0 +1,37 @@
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
} from "@probo/ui";
import type { ReactNode } from "react";
type Props = {
trigger: ReactNode;
};
export default function NewRiskDialog({ trigger }: Props) {
const { __ } = useTranslate();
return (
<Dialog
onClose={() => {}}
trigger={trigger}
title={<Breadcrumb items={[__("Risks"), __("New Risk")]}></Breadcrumb>}
>
<form>
<DialogContent>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Velit harum
maiores dolor hic nisi repudiandae autem quod rem eveniet iure
quibusdam sint iste numquam, suscipit ad delectus aspernatur. Quo,
amet.
</DialogContent>
<DialogFooter>
<Button type="submit">{__("Create risk")}</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,17 @@
import { Button, PageHeader } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { IconPlusLarge } from "@probo/ui";
import NewRiskDialog from "./NewRiskDialog";
export default function RisksPage() {
const { __ } = useTranslate();
return (
<div className="space-y-6">
<PageHeader title={__("Risks")}>
<NewRiskDialog
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
</PageHeader>
</div>
);
}

View File

@@ -1,10 +1,14 @@
import { createBrowserRouter, useRouteError } from "react-router";
import {
createBrowserRouter,
useRouteError,
type RouteObject,
} from "react-router";
import { MainLayout } from "./layouts/MainLayout";
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
import { lazy, Suspense, type FC } from "react";
import { UnAuthenticatedError } from "./providers/RelayProviders";
import { AuthLayout } from "@probo/ui";
import { LoginPage } from "./pages/auth/LoginPage";
function Demo() {
function ErrorBoundary() {
const error = useRouteError();
if (error instanceof UnAuthenticatedError) {
return <div></div>;
@@ -13,26 +17,71 @@ function Demo() {
return <div>error</div>;
}
export const routes = createBrowserRouter([
type Route = {
path: string;
Component: FC;
children?: Route[];
ErrorBoundary?: FC;
fallback?: FC;
};
const routes = [
{
path: "/auth",
Component: AuthLayout,
children: [
{
path: "login",
element: <LoginPage />,
Component: lazy(() => import("./pages/auth/LoginPage")),
},
],
},
{
path: "/",
Component: MainLayout,
ErrorBoundary: Demo,
Component: CenteredLayout,
fallback: CenteredLayoutSkeleton,
children: [
{
path: "/",
element: <div>Home</div>,
path: "",
Component: lazy(() => import("./pages/OrganizationsPage")),
},
{
path: "organizations/new",
Component: lazy(
() => import("./pages/organizations/NewOrganizationPage")
),
},
],
},
]);
{
path: "/organizations/:organizationId",
Component: MainLayout,
ErrorBoundary: ErrorBoundary,
children: [
{
path: "risks",
Component: lazy(() => import("./pages/organizations/risks/RisksPage")),
},
],
},
] satisfies Route[];
/**
* Wrap component with a suspense to handle lazy loading & relay loading states
*/
function routeTransformer(route: Route): RouteObject {
if ("fallback" in route && route.fallback) {
return {
...route,
children: route.children?.map(routeTransformer),
Component: () => (
<Suspense fallback={<route.fallback />}>
<route.Component />
</Suspense>
),
} as RouteObject;
}
return route as RouteObject;
}
export const router = createBrowserRouter(routes.map(routeTransformer));

4334
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,6 @@
},
"license": "MIT",
"volta": {
"node": "22.15.0"
"node": "22.15.1"
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "@probo/hooks",
"version": "1.0.0",
"description": "",
"main": "./src/index.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write ./src"
},
"prettier": "@probo/prettier",
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@probo/prettier": "1.0.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}
}

View File

@@ -0,0 +1 @@
export { usePageTitle } from "./usePageTitle";

View File

@@ -0,0 +1,10 @@
import { useEffect } from "react";
export function usePageTitle(title: string) {
useEffect(() => {
document.title = title + " - Probo";
return () => {
document.title = "Probo";
};
}, [title]);
}

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"lib": ["ES2021", "dom"]
}
}

View File

@@ -1,3 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
@import 'tailwindcss';
@import '../src/theme.css';
@import "tailwindcss";
@import "tw-animate-css";
@import "../src/theme.css";

View File

@@ -1,18 +1,19 @@
import { tv } from "tailwind-variants";
type Props = {
fullName: string;
name: string;
src?: string | null;
size?: "s" | "m" | "l" | "xl";
};
const avatar = tv({
base: "bg-txt-success text-sm text-invert rounded-full text-xxs font-semibold flex items-center justify-center",
base: "bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center",
variants: {
size: {
s: "size-5",
m: "size-6",
l: "size-8",
xl: "size-16",
s: "size-5 text-xss",
m: "size-6 text-xxs",
l: "size-8 text-sm",
xl: "size-16 text-3xl",
},
},
defaultVariants: {
@@ -20,8 +21,13 @@ const avatar = tv({
},
});
export function Avatar({ fullName, size = "m" }: Props) {
return <div className={avatar({ size })}>{extractInitials(fullName)}</div>;
export function Avatar({ name, src, size = "m" }: Props) {
if (src) {
return <img className={avatar({ size })} src={src} alt={name} />;
}
return (
<div className={avatar({ size })}>{extractInitials(name ?? "")}</div>
);
}
function extractInitials(name: string) {

View File

@@ -0,0 +1,29 @@
import { Breadcrumb } from "./Breadcrumb";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Breadcrumb",
component: Breadcrumb,
argTypes: {},
} satisfies Meta<typeof Breadcrumb>;
type Story = StoryObj<typeof Breadcrumb>;
export const Default: Story = {
args: {
items: [
{
label: "Home",
to: "/",
},
{
label: "Library",
to: "/library",
},
{
label: "Data",
to: "/data",
},
],
},
};

View File

@@ -0,0 +1,51 @@
import clsx from "clsx";
import { Link } from "react-router";
import { IconChevronRight } from "../Icons";
type Props = {
items: (ItemProps | string)[];
};
type ItemProps = {
label: string;
to?: string;
active?: boolean;
};
export function Breadcrumb({ items }: Props) {
return (
<div className="flex items-center gap-[6px] text-txt-tertiary">
{items.map((item, index) => (
<>
{index > 0 && <IconChevronRight size={12} />}
<BreadcrumbItem
key={index}
{...(typeof item === "string" ? { label: item } : item)}
active={index === items.length - 1}
/>
</>
))}
</div>
);
}
function BreadcrumbItem({ label, to, active }: ItemProps) {
const className = clsx(
"text-sm px-1 rounded-sm h-5",
active && "text-txt-primary font-medium",
);
if (to) {
return (
<Link
to={to}
className={clsx(
className,
"hover:bg-tertiary-hover active:bg-tertiary-pressed",
)}
>
{label}
</Link>
);
}
return <span className={className}>{label}</span>;
}

View File

@@ -0,0 +1,17 @@
import { Card } from "./Card";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Card",
component: Card,
argTypes: {},
} satisfies Meta<typeof Card>;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
args: {
padded: true,
children: "lorem ipsum",
},
};

View File

@@ -0,0 +1,27 @@
import type { PropsWithChildren } from "react";
import { tv } from "tailwind-variants";
import { Slot } from "../Slot";
type Props = PropsWithChildren<{
padded?: boolean;
className?: string;
asChild?: boolean;
}>;
const card = tv({
base: "border border-border-low surface-1 rounded-2xl",
variants: {
padded: {
true: "p-6",
},
},
});
export function Card({ padded = false, children, className, asChild }: Props) {
const Component = asChild ? Slot : "div";
return (
<Component className={card({ padded, className })}>
{children}
</Component>
);
}

View File

@@ -17,7 +17,7 @@ export function Dropdown({ children, toggle, className }: Props) {
<DropdownMenu.Content
className={clsx(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 p-2 shadow-mid min-w-[8rem] bg-surface-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
"z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
className,
)}
sideOffset={5}

View File

@@ -6,6 +6,7 @@ import {
} from "react";
import { clsx } from "clsx";
import { IconCollapse, IconExpand } from "../Icons";
import { Button } from "../Button/Button";
const sidebarContext = createContext({ open: true });
@@ -15,21 +16,17 @@ export function Sidebar({ children }: PropsWithChildren) {
<sidebarContext.Provider value={{ open }}>
<aside
className={clsx(
"border-r border-border-solid relative pt-12 flex-none",
"border-r border-border-solid relative pt-16 flex-none",
open ? "px-4 w-[260px]" : "px-2",
)}
>
{children}
<button
<Button
variant="tertiary"
icon={open ? IconCollapse : IconExpand}
onClick={() => setOpen(!open)}
className="cursor-pointer absolute bottom-4 left-4"
>
{open ? (
<IconCollapse size={16} />
) : (
<IconExpand size={16} />
)}
</button>
className="absolute bottom-4 left-4"
/>
</aside>
</sidebarContext.Provider>
);

View File

@@ -0,0 +1,16 @@
import { Skeleton } from "./Skeleton";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Skeleton",
component: Skeleton,
argTypes: {},
} satisfies Meta<typeof Skeleton>;
type Story = StoryObj<typeof Skeleton>;
export const Default: Story = {
args: {
className: "w-50 h-4",
},
};

View File

@@ -0,0 +1,16 @@
import { clsx } from "clsx";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx(
"animate-pulse rounded-md bg-border-low",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,29 @@
import clsx from "clsx";
import React from "react";
import { cloneElement, isValidElement, Children } from "react";
import type { ReactNode } from "react";
export type AsChildProps<DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| { asChild: true; children: ReactNode };
export function Slot({
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: ReactNode;
}) {
if (isValidElement(children)) {
return cloneElement(children, {
...props,
// @ts-expect-error children.props can be undefined it's ok
...children.props,
// @ts-expect-error children.props.className can be undefined it's ok
className: clsx(props.className, children.props.className),
});
}
if (Children.count(children) > 1) {
Children.only(null);
}
return null;
}

View File

@@ -1,13 +1,12 @@
import logo from "../assets/android-chrome-512x512.png";
import { useTranslate } from "@probo/i18n";
import { Outlet } from "react-router";
import { Toasts } from "../Atoms/Toasts/Toasts";
export function AuthLayout() {
const { __ } = useTranslate();
return (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen">
<div className="bg-surface-0 flex flex-col items-center justify-center">
<div className="bg-level-0 flex flex-col items-center justify-center">
<div className="max-w-112">
<Outlet />
</div>
@@ -21,7 +20,6 @@ export function AuthLayout() {
</span>
</div>
</div>
<Toasts />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { CenteredLayout } from "./CenteredLayout";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Layouts/CenteredLayout",
component: CenteredLayout,
argTypes: {},
} satisfies Meta<typeof CenteredLayout>;
type Story = StoryObj<typeof CenteredLayout>;
export const Default: Story = {
args: {
children: "lorem ipsum",
},
};

View File

@@ -0,0 +1,24 @@
import type { PropsWithChildren } from "react";
import { Outlet } from "react-router";
import { Skeleton } from "../Atoms/Skeleton/Skeleton";
export function CenteredLayout({ children }: PropsWithChildren) {
return (
<div className="grid place-items-center h-screen">
<div className="w-full max-w-2xl flex flex-col items-center">
{children ?? <Outlet />}
</div>
</div>
);
}
export function CenteredLayoutSkeleton() {
return (
<CenteredLayout>
<div className="w-full max-w-2xl flex flex-col items-center space-y-6">
<Skeleton className="w-77 h-9" />
<Skeleton className="w-full h-20" />
</div>
</CenteredLayout>
);
}

View File

@@ -11,8 +11,18 @@ type Props = PropsWithChildren<{
export function Layout({ header, sidebar, children }: Props) {
return (
<div>
<header className="absolute left-0 right-0 px-4 flex items-center border-b border-border-solid h-12">
<header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-1">
<Logo className="w-12 h-5" />
<svg
className="mx-3"
width="8"
height="18"
viewBox="0 0 8 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 17L7 1" stroke="#C3C8C2" />
</svg>
{header}
</header>
<div className="flex h-screen">

View File

@@ -0,0 +1,37 @@
import { Button } from "../../Atoms/Button/Button";
import { Dialog, DialogContent, DialogFooter } from "./Dialog";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Dialog",
component: Dialog,
argTypes: {},
} satisfies Meta<typeof Dialog>;
type Story = StoryObj<typeof Dialog>;
export const Default: Story = {
render: (args) => {
return (
<Dialog
{...args}
onClose={() => {}}
trigger={<Button>Open dialog</Button>}
title="Edit profile"
>
<DialogContent>
<div>
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Voluptate, voluptas. Doloribus incidunt cum laboriosam
nulla magni soluta voluptatum omnis, sapiente minus
corporis impedit explicabo vero praesentium, fugit
possimus facilis rem.
</div>
</DialogContent>
<DialogFooter>
<Button>Save</Button>
</DialogFooter>
</Dialog>
);
},
};

View File

@@ -0,0 +1,72 @@
import {
Root,
Trigger,
Portal,
Overlay,
Content,
Title,
Close,
} from "@radix-ui/react-dialog";
import { IconCrossLargeX } from "../../Atoms/Icons";
import type { ReactNode } from "react";
import { Button } from "../../Atoms/Button/Button";
import { useTranslate } from "@probo/i18n";
type Props = {
onClose: () => void;
trigger: ReactNode;
title: ReactNode;
children?: ReactNode;
};
export function Dialog({ onClose, trigger, title, children }: Props) {
return (
<Root>
<Trigger asChild>{trigger}</Trigger>
<Portal>
<Overlay className="fixed inset-0 z-50 bg-dialog/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<div className="fixed grid place-items-center inset-0 z-50">
<Content className="bg-level-2 rounded-2xl max-w-5xl w-[95%]">
<div className="flex justify-between items-center p-3 border-b border-b-border-low">
<Title className="text-sm font-medium">
{title}
</Title>
<Close asChild>
<Button
variant="tertiary"
icon={IconCrossLargeX}
/>
</Close>
</div>
{children}
</Content>
</div>
</Portal>
</Root>
);
}
export function DialogFooter({ children }: { children?: ReactNode }) {
const { __ } = useTranslate();
return (
<footer className="flex justify-end items-center p-3 border-t border-t-border-low gap-2">
<Close asChild>
<Button variant="secondary">{__("Cancel")}</Button>
</Close>
{children}
</footer>
);
}
export function DialogContent({ children }: { children: ReactNode }) {
return (
<div
className="overflow-y-auto"
style={{
maxHeight: "min(640px, calc(100vh - 140px))",
}}
>
{children}
</div>
);
}

View File

@@ -2,12 +2,18 @@
export { Layout } from "./Layouts/Layout";
export { AuthLayout } from "./Layouts/AuthLayout";
export { ErrorLayout } from "./Layouts/ErrorLayout";
export {
CenteredLayout,
CenteredLayoutSkeleton,
} from "./Layouts/CenteredLayout";
// Atoms
export * from "./Atoms/Icons";
export { Logo } from "./Atoms/Logo/Logo";
export { SidebarItem } from "./Atoms/Sidebar/SidebarItem";
export { Button } from "./Atoms/Button/Button";
export { Card } from "./Atoms/Card/Card";
export { Breadcrumb } from "./Atoms/Breadcrumb/Breadcrumb";
export {
Dropdown,
DropdownSeparator,
@@ -22,6 +28,8 @@ export {
UserDropdownItem,
} from "./Molecules/UserDropdown/UserDropdown";
export { PageHeader } from "./Molecules/PageHeader/PageHeader";
export { Skeleton } from "./Atoms/Skeleton/Skeleton";
export { Dialog, DialogContent, DialogFooter } from "./Molecules/Dialog/Dialog";
// Hooks
export { useToast } from "./Atoms/Toasts/Toasts";
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";

View File

@@ -10,6 +10,7 @@
/* Text colors */
--color-txt-primary: #141e12;
--color-invert: #fcfdfc;
--color-txt-invert: var(--color-invert);
--color-txt-secondary: #6b716a;
--color-txt-tertiary: #818780;
--color-txt-quaternary: #d8dcd8;
@@ -162,5 +163,3 @@
0px 2px 4px 0px rgba(0, 0, 0, 0.08),
0px 2px 12px 0px rgba(0, 0, 0, 0.06);
}
@import "tw-animate-css";

6
relay.config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"src": "./apps/console2/src",
"schema": "./pkg/server/api/console/v1/schema.graphql",
"language": "typescript",
"eagerEsModules": true
}