Rewrite identity and access management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -31,7 +31,7 @@ import { formatDate } from "@probo/helpers";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { UnAuthenticatedError } from "/providers/RelayProviders";
|
||||
import { UnAuthenticatedError } from "@probo/relay";
|
||||
|
||||
interface APIKey {
|
||||
id: string;
|
||||
@@ -56,10 +56,14 @@ interface Organization {
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
expiresIn: z.enum(["1month", "3months", "6months", "1year"]),
|
||||
organizations: z.array(z.object({
|
||||
organizationId: z.string(),
|
||||
role: z.string(),
|
||||
})).min(1, "At least one organization is required"),
|
||||
organizations: z
|
||||
.array(
|
||||
z.object({
|
||||
organizationId: z.string(),
|
||||
role: z.string(),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one organization is required"),
|
||||
});
|
||||
|
||||
type CreateFormData = z.infer<typeof createSchema>;
|
||||
@@ -79,19 +83,24 @@ export default function APIKeysPage() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [selectedOrganizations, setSelectedOrganizations] = useState<string[]>([]);
|
||||
const [organizationRoles, setOrganizationRoles] = useState<Record<string, string>>({});
|
||||
const [selectedOrganizations, setSelectedOrganizations] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [organizationRoles, setOrganizationRoles] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [editingAPIKey, setEditingAPIKey] = useState<APIKey | null>(null);
|
||||
const [editingName, setEditingName] = useState<string>("");
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const { formState, handleSubmit, reset, control, setValue, register } = useFormWithSchema(createSchema, {
|
||||
defaultValues: {
|
||||
name: new Date().toISOString().split('T')[0],
|
||||
expiresIn: "1month",
|
||||
organizations: [],
|
||||
},
|
||||
});
|
||||
const { formState, handleSubmit, reset, control, setValue, register } =
|
||||
useFormWithSchema(createSchema, {
|
||||
defaultValues: {
|
||||
name: new Date().toISOString().split("T")[0],
|
||||
expiresIn: "1month",
|
||||
organizations: [],
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@@ -99,14 +108,16 @@ export default function APIKeysPage() {
|
||||
|
||||
const fetchAPIKeys = async () => {
|
||||
try {
|
||||
const response = await fetch('/connect/api-keys', { credentials: 'include' });
|
||||
const response = await fetch("/connect/api-keys", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch API keys');
|
||||
throw new Error("Failed to fetch API keys");
|
||||
}
|
||||
const data: { apiKeys: APIKey[] } = await response.json();
|
||||
setApiKeys(data.apiKeys);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch API keys:', err);
|
||||
console.error("Failed to fetch API keys:", err);
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Failed to load API keys"),
|
||||
@@ -119,32 +130,40 @@ export default function APIKeysPage() {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [apiKeysResponse, organizationsResponse] = await Promise.all([
|
||||
fetch('/connect/api-keys', { credentials: 'include' }),
|
||||
fetch('/connect/organizations?role=OWNER', { credentials: 'include' }),
|
||||
fetch("/connect/api-keys", { credentials: "include" }),
|
||||
fetch("/connect/organizations?role=OWNER", {
|
||||
credentials: "include",
|
||||
}),
|
||||
]);
|
||||
|
||||
if (apiKeysResponse.status === 401 || organizationsResponse.status === 401) {
|
||||
if (
|
||||
apiKeysResponse.status === 401 ||
|
||||
organizationsResponse.status === 401
|
||||
) {
|
||||
setError(new UnAuthenticatedError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKeysResponse.ok) {
|
||||
throw new Error('Failed to fetch API keys');
|
||||
throw new Error("Failed to fetch API keys");
|
||||
}
|
||||
|
||||
if (!organizationsResponse.ok) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
throw new Error("Failed to fetch organizations");
|
||||
}
|
||||
|
||||
const apiKeysData: { apiKeys: APIKey[] } = await apiKeysResponse.json();
|
||||
const orgsData: { organizations: Organization[] } = await organizationsResponse.json();
|
||||
const orgsData: { organizations: Organization[] } =
|
||||
await organizationsResponse.json();
|
||||
|
||||
const authenticatedOrgs = orgsData.organizations.filter(org => org.authStatus === "authenticated");
|
||||
const authenticatedOrgs = orgsData.organizations.filter(
|
||||
(org) => org.authStatus === "authenticated"
|
||||
);
|
||||
|
||||
setApiKeys(apiKeysData.apiKeys);
|
||||
setOrganizations(authenticatedOrgs);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
console.error("Failed to fetch data:", err);
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Failed to load data"),
|
||||
@@ -179,12 +198,12 @@ export default function APIKeysPage() {
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await fetch('/connect/api-keys', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/connect/api-keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
@@ -193,7 +212,7 @@ export default function APIKeysPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create API key');
|
||||
throw new Error("Failed to create API key");
|
||||
}
|
||||
|
||||
const data: { apiKey: APIKey; key: string } = await response.json();
|
||||
@@ -224,9 +243,9 @@ export default function APIKeysPage() {
|
||||
const handleEdit = (apiKey: APIKey) => {
|
||||
setEditingAPIKey(apiKey);
|
||||
setEditingName(apiKey.name);
|
||||
const orgIds = apiKey.organizations.map(org => org.organizationId);
|
||||
const orgIds = apiKey.organizations.map((org) => org.organizationId);
|
||||
const roles: Record<string, string> = {};
|
||||
apiKey.organizations.forEach(org => {
|
||||
apiKey.organizations.forEach((org) => {
|
||||
roles[org.organizationId] = org.role;
|
||||
});
|
||||
setSelectedOrganizations(orgIds);
|
||||
@@ -239,16 +258,16 @@ export default function APIKeysPage() {
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const response = await fetch('/connect/api-keys', {
|
||||
method: 'PUT',
|
||||
const response = await fetch("/connect/api-keys", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
id: editingAPIKey.id,
|
||||
name: editingName,
|
||||
organizations: selectedOrganizations.map(id => ({
|
||||
organizations: selectedOrganizations.map((id) => ({
|
||||
organizationId: id,
|
||||
role: organizationRoles[id] || "FULL",
|
||||
})),
|
||||
@@ -256,7 +275,7 @@ export default function APIKeysPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update API key');
|
||||
throw new Error("Failed to update API key");
|
||||
}
|
||||
|
||||
await fetchAPIKeys();
|
||||
@@ -286,20 +305,20 @@ export default function APIKeysPage() {
|
||||
async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/connect/api-keys', {
|
||||
method: 'DELETE',
|
||||
const response = await fetch("/connect/api-keys", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete API key');
|
||||
throw new Error("Failed to delete API key");
|
||||
}
|
||||
|
||||
setApiKeys(apiKeys.filter(key => key.id !== id));
|
||||
setApiKeys(apiKeys.filter((key) => key.id !== id));
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("API Key deleted successfully"),
|
||||
@@ -317,7 +336,9 @@ export default function APIKeysPage() {
|
||||
}
|
||||
},
|
||||
{
|
||||
message: __(`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`),
|
||||
message: __(
|
||||
`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`
|
||||
),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -326,11 +347,11 @@ export default function APIKeysPage() {
|
||||
setIsLoadingKey(true);
|
||||
try {
|
||||
const response = await fetch(`/connect/api-keys/${id}`, {
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load API key');
|
||||
throw new Error("Failed to load API key");
|
||||
}
|
||||
|
||||
const data: { key: string } = await response.json();
|
||||
@@ -400,7 +421,7 @@ export default function APIKeysPage() {
|
||||
|
||||
return (
|
||||
<Card key={apiKey.id} padded className="w-full">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{apiKey.name}</h3>
|
||||
@@ -464,7 +485,9 @@ export default function APIKeysPage() {
|
||||
{__("Create an API key")}
|
||||
</h2>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Generate a new API key for programmatic access to your organization")}
|
||||
{__(
|
||||
"Generate a new API key for programmatic access to your organization"
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
@@ -516,31 +539,34 @@ export default function APIKeysPage() {
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
const allSelected = selectedOrganizations.length === organizations.length;
|
||||
const allSelected =
|
||||
selectedOrganizations.length === organizations.length;
|
||||
if (allSelected) {
|
||||
setSelectedOrganizations([]);
|
||||
setOrganizationRoles({});
|
||||
setValue("organizations", []);
|
||||
} else {
|
||||
const allOrgIds = organizations.map(org => org.id);
|
||||
const allOrgIds = organizations.map((org) => org.id);
|
||||
const newRoles: Record<string, string> = {};
|
||||
allOrgIds.forEach(id => {
|
||||
allOrgIds.forEach((id) => {
|
||||
newRoles[id] = organizationRoles[id] || "FULL";
|
||||
});
|
||||
setSelectedOrganizations(allOrgIds);
|
||||
setOrganizationRoles(newRoles);
|
||||
setValue(
|
||||
"organizations",
|
||||
allOrgIds.map(id => ({
|
||||
allOrgIds.map((id) => ({
|
||||
organizationId: id,
|
||||
role: newRoles[id]
|
||||
role: newRoles[id],
|
||||
}))
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="text-xs h-7 min-h-7"
|
||||
>
|
||||
{selectedOrganizations.length === organizations.length ? __("Clear All") : __("Select All")}
|
||||
{selectedOrganizations.length === organizations.length
|
||||
? __("Clear All")
|
||||
: __("Select All")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -556,9 +582,7 @@ export default function APIKeysPage() {
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th width={180}>{__("Role")}</Th>
|
||||
<Th width={100}>
|
||||
<div className="flex justify-end">
|
||||
{__("Access")}
|
||||
</div>
|
||||
<div className="flex justify-end">{__("Access")}</div>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
@@ -570,30 +594,33 @@ export default function APIKeysPage() {
|
||||
{org.name}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="min-h-[36px] flex items-center">
|
||||
{selectedOrganizations.includes(org.id) ? (
|
||||
<Select
|
||||
value={organizationRoles[org.id] || "FULL"}
|
||||
onValueChange={(role) => {
|
||||
const newRoles = { ...organizationRoles, [org.id]: role };
|
||||
setOrganizationRoles(newRoles);
|
||||
setValue(
|
||||
"organizations",
|
||||
selectedOrganizations.map(id => ({
|
||||
organizationId: id,
|
||||
role: newRoles[id] || "FULL"
|
||||
}))
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Option value="FULL">{__("Full")}</Option>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-txt-tertiary">—</span>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="min-h-[36px] flex items-center">
|
||||
{selectedOrganizations.includes(org.id) ? (
|
||||
<Select
|
||||
value={organizationRoles[org.id] || "FULL"}
|
||||
onValueChange={(role) => {
|
||||
const newRoles = {
|
||||
...organizationRoles,
|
||||
[org.id]: role,
|
||||
};
|
||||
setOrganizationRoles(newRoles);
|
||||
setValue(
|
||||
"organizations",
|
||||
selectedOrganizations.map((id) => ({
|
||||
organizationId: id,
|
||||
role: newRoles[id] || "FULL",
|
||||
}))
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Option value="FULL">{__("Full")}</Option>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-txt-tertiary">—</span>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<Checkbox
|
||||
@@ -602,21 +629,26 @@ export default function APIKeysPage() {
|
||||
let newSelected: string[];
|
||||
const newRoles = { ...organizationRoles };
|
||||
if (checked) {
|
||||
newSelected = [...selectedOrganizations, org.id];
|
||||
newSelected = [
|
||||
...selectedOrganizations,
|
||||
org.id,
|
||||
];
|
||||
if (!newRoles[org.id]) {
|
||||
newRoles[org.id] = "FULL";
|
||||
}
|
||||
} else {
|
||||
newSelected = selectedOrganizations.filter(id => id !== org.id);
|
||||
newSelected = selectedOrganizations.filter(
|
||||
(id) => id !== org.id
|
||||
);
|
||||
delete newRoles[org.id];
|
||||
}
|
||||
setSelectedOrganizations(newSelected);
|
||||
setOrganizationRoles(newRoles);
|
||||
setValue(
|
||||
"organizations",
|
||||
newSelected.map(id => ({
|
||||
newSelected.map((id) => ({
|
||||
organizationId: id,
|
||||
role: newRoles[id] || "FULL"
|
||||
role: newRoles[id] || "FULL",
|
||||
}))
|
||||
);
|
||||
}}
|
||||
@@ -660,14 +692,15 @@ export default function APIKeysPage() {
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
const allSelected = selectedOrganizations.length === organizations.length;
|
||||
const allSelected =
|
||||
selectedOrganizations.length === organizations.length;
|
||||
if (allSelected) {
|
||||
setSelectedOrganizations([]);
|
||||
setOrganizationRoles({});
|
||||
} else {
|
||||
const allOrgIds = organizations.map(org => org.id);
|
||||
const allOrgIds = organizations.map((org) => org.id);
|
||||
const newRoles: Record<string, string> = {};
|
||||
allOrgIds.forEach(id => {
|
||||
allOrgIds.forEach((id) => {
|
||||
newRoles[id] = organizationRoles[id] || "FULL";
|
||||
});
|
||||
setSelectedOrganizations(allOrgIds);
|
||||
@@ -676,7 +709,9 @@ export default function APIKeysPage() {
|
||||
}}
|
||||
className="text-xs h-7 min-h-7"
|
||||
>
|
||||
{selectedOrganizations.length === organizations.length ? __("Clear All") : __("Select All")}
|
||||
{selectedOrganizations.length === organizations.length
|
||||
? __("Clear All")
|
||||
: __("Select All")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -692,9 +727,7 @@ export default function APIKeysPage() {
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th width={180}>{__("Role")}</Th>
|
||||
<Th width={100}>
|
||||
<div className="flex justify-end">
|
||||
{__("Access")}
|
||||
</div>
|
||||
<div className="flex justify-end">{__("Access")}</div>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
@@ -712,7 +745,10 @@ export default function APIKeysPage() {
|
||||
<Select
|
||||
value={organizationRoles[org.id] || "FULL"}
|
||||
onValueChange={(role) => {
|
||||
const newRoles = { ...organizationRoles, [org.id]: role };
|
||||
const newRoles = {
|
||||
...organizationRoles,
|
||||
[org.id]: role,
|
||||
};
|
||||
setOrganizationRoles(newRoles);
|
||||
}}
|
||||
>
|
||||
@@ -731,12 +767,17 @@ export default function APIKeysPage() {
|
||||
let newSelected: string[];
|
||||
const newRoles = { ...organizationRoles };
|
||||
if (checked) {
|
||||
newSelected = [...selectedOrganizations, org.id];
|
||||
newSelected = [
|
||||
...selectedOrganizations,
|
||||
org.id,
|
||||
];
|
||||
if (!newRoles[org.id]) {
|
||||
newRoles[org.id] = "FULL";
|
||||
}
|
||||
} else {
|
||||
newSelected = selectedOrganizations.filter(id => id !== org.id);
|
||||
newSelected = selectedOrganizations.filter(
|
||||
(id) => id !== org.id
|
||||
);
|
||||
delete newRoles[org.id];
|
||||
}
|
||||
setSelectedOrganizations(newSelected);
|
||||
@@ -754,7 +795,13 @@ export default function APIKeysPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleUpdate} disabled={isUpdating || (selectedOrganizations.length === 0 && !editingName.trim())}>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={
|
||||
isUpdating ||
|
||||
(selectedOrganizations.length === 0 && !editingName.trim())
|
||||
}
|
||||
>
|
||||
{isUpdating ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconCircleCheck,
|
||||
} from "@probo/ui";
|
||||
import { PDFPreview } from "../components/documents/PDFPreview";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import clsx from "clsx";
|
||||
import { sprintf } from "@probo/helpers";
|
||||
@@ -63,7 +62,7 @@ export default function DocumentSigningRequestsPage() {
|
||||
async function fetchDocuments() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
buildEndpoint("/api/console/v1/documents/signing-requests"),
|
||||
"/api/console/v1/documents/signing-requests",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -114,9 +113,7 @@ export default function DocumentSigningRequestsPage() {
|
||||
setSigning(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
buildEndpoint(
|
||||
`/api/console/v1/documents/signing-requests/${docToSign.document_version_id}/sign`
|
||||
),
|
||||
`/api/console/v1/documents/signing-requests/${docToSign.document_version_id}/sign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -235,9 +232,7 @@ export default function DocumentSigningRequestsPage() {
|
||||
|
||||
// Build PDF URL with watermark
|
||||
const pdfUrl = token
|
||||
? `${buildEndpoint(
|
||||
`/api/console/v1/documents/signing-requests/${currentDoc.document_version_id}/pdf`
|
||||
)}?token=${encodeURIComponent(token)}`
|
||||
? `${`/api/console/v1/documents/signing-requests/${currentDoc.document_version_id}/pdf`}?token=${encodeURIComponent(token)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -250,7 +245,10 @@ export default function DocumentSigningRequestsPage() {
|
||||
<div className="grid lg:grid-cols-2 min-h-0 h-full">
|
||||
<div className="max-w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide">
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
{sprintf(__("%s requests your signature"), signingData.organizationName)}
|
||||
{sprintf(
|
||||
__("%s requests your signature"),
|
||||
signingData.organizationName
|
||||
)}
|
||||
</h1>
|
||||
{allSigned ? (
|
||||
<p className="text-txt-secondary text-base">
|
||||
@@ -266,7 +264,10 @@ export default function DocumentSigningRequestsPage() {
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="divide-y divide-border-solid">
|
||||
{(() => {
|
||||
const renderDocumentItem = (doc: Document, index: number) => (
|
||||
const renderDocumentItem = (
|
||||
doc: Document,
|
||||
index: number
|
||||
) => (
|
||||
<div
|
||||
key={doc.document_version_id}
|
||||
className={clsx(
|
||||
@@ -307,15 +308,15 @@ export default function DocumentSigningRequestsPage() {
|
||||
doc.signed
|
||||
? "bg-green-100 text-green-800"
|
||||
: index === currentDocIndex
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
)}
|
||||
>
|
||||
{doc.signed
|
||||
? __("Signed")
|
||||
: index === currentDocIndex
|
||||
? __("In review")
|
||||
: __("Waiting signature")}
|
||||
? __("In review")
|
||||
: __("Waiting signature")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,8 +353,12 @@ export default function DocumentSigningRequestsPage() {
|
||||
const currentIsLast = currentDocIndex === totalDocs - 1;
|
||||
|
||||
// Calculate hidden docs before and after current
|
||||
const hiddenBeforeCurrent = currentIsFirst ? 0 : currentDocIndex - 1;
|
||||
const hiddenAfterCurrent = currentIsLast ? 0 : totalDocs - currentDocIndex - 2;
|
||||
const hiddenBeforeCurrent = currentIsFirst
|
||||
? 0
|
||||
: currentDocIndex - 1;
|
||||
const hiddenAfterCurrent = currentIsLast
|
||||
? 0
|
||||
: totalDocs - currentDocIndex - 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -367,12 +372,16 @@ export default function DocumentSigningRequestsPage() {
|
||||
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
|
||||
>
|
||||
<span className="text-txt-tertiary">•••</span>
|
||||
{sprintf(__("Show %s more documents"), hiddenBeforeCurrent)}
|
||||
{sprintf(
|
||||
__("Show %s more documents"),
|
||||
hiddenBeforeCurrent
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Current document (if not first) */}
|
||||
{!currentIsFirst && renderDocumentItem(currentDoc, currentDocIndex)}
|
||||
{!currentIsFirst &&
|
||||
renderDocumentItem(currentDoc, currentDocIndex)}
|
||||
|
||||
{/* Show more button for documents AFTER current (upcoming documents) */}
|
||||
{hiddenAfterCurrent > 0 && (
|
||||
@@ -381,7 +390,10 @@ export default function DocumentSigningRequestsPage() {
|
||||
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
|
||||
>
|
||||
<span className="text-txt-tertiary">•••</span>
|
||||
{sprintf(__("Show %s more documents"), hiddenAfterCurrent)}
|
||||
{sprintf(
|
||||
__("Show %s more documents"),
|
||||
hiddenAfterCurrent
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -390,9 +402,7 @@ export default function DocumentSigningRequestsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
<p className="text-txt-secondary text-sm mb-6">
|
||||
{__(
|
||||
"Please review the document carefully before signing."
|
||||
)}
|
||||
{__("Please review the document carefully before signing.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -421,10 +431,7 @@ export default function DocumentSigningRequestsPage() {
|
||||
</>
|
||||
)}
|
||||
{currentDoc.signed && !isLastDocument && (
|
||||
<Button
|
||||
onClick={handleNextDocument}
|
||||
className="h-10 w-full mt-4"
|
||||
>
|
||||
<Button onClick={handleNextDocument} className="h-10 w-full mt-4">
|
||||
{__("Next Document")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
import { useState } from "react";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -22,17 +21,14 @@ export default function ForgotPasswordPage() {
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const response = await fetch(
|
||||
buildEndpoint("/connect/forget-password"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
const response = await fetch("/connect/forget-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
|
||||
import type { FormEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { __ } = useTranslate();
|
||||
@@ -33,7 +32,7 @@ export default function LoginPage() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(buildEndpoint("/connect/login"), {
|
||||
const res = await fetch("/connect/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -68,7 +67,7 @@ export default function LoginPage() {
|
||||
setIsChecking(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(buildEndpoint("/connect/check-sso"), {
|
||||
const res = await fetch("/connect/check-sso", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -78,15 +77,15 @@ export default function LoginPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || __("SSO not available for this email domain"));
|
||||
throw new Error(
|
||||
error.message || __("SSO not available for this email domain")
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ssoAvailable && data.samlConfigId) {
|
||||
window.location.href = buildEndpoint(
|
||||
`/connect/saml/login/${data.samlConfigId}`
|
||||
);
|
||||
window.location.href = `/connect/saml/login/${data.samlConfigId}`;
|
||||
} else {
|
||||
throw new Error(__("SSO not available for this email domain"));
|
||||
}
|
||||
@@ -115,10 +114,7 @@ export default function LoginPage() {
|
||||
{__("Choose your login method")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setMode("password")}
|
||||
>
|
||||
<Button className="w-full" onClick={() => setMode("password")}>
|
||||
{__("Login with Email")}
|
||||
</Button>
|
||||
|
||||
@@ -146,7 +142,10 @@ export default function LoginPage() {
|
||||
|
||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||
{__("Don't have an account ?")}{" "}
|
||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
||||
<Link
|
||||
to="/auth/register"
|
||||
className="underline hover:text-txt-primary"
|
||||
>
|
||||
{__("Register")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -206,7 +205,10 @@ export default function LoginPage() {
|
||||
|
||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||
{__("Don't have an account ?")}{" "}
|
||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
||||
<Link
|
||||
to="/auth/register"
|
||||
className="underline hover:text-txt-primary"
|
||||
>
|
||||
{__("Register")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -235,9 +237,7 @@ export default function LoginPage() {
|
||||
<span className="text-sm">{__("Back")}</span>
|
||||
</button>
|
||||
|
||||
<h1 className="text-center text-2xl font-bold">
|
||||
{__("Login with SSO")}
|
||||
</h1>
|
||||
<h1 className="text-center text-2xl font-bold">{__("Login with SSO")}</h1>
|
||||
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
||||
{__("Enter your work email to continue with SSO")}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -25,17 +24,14 @@ export default function RegisterPage() {
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const response = await fetch(
|
||||
buildEndpoint("/connect/register"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
const response = await fetch("/connect/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
// Registration failed
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -20,15 +19,12 @@ export default function ResetPasswordPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { register, handleSubmit, formState } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
}
|
||||
);
|
||||
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -43,20 +39,17 @@ export default function ResetPasswordPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildEndpoint("/connect/reset-password"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
password: data.password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const response = await fetch("/connect/reset-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
// Reset failed
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -50,21 +49,18 @@ export default function SignupFromInvitationPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildEndpoint("/connect/signup-from-invitation"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
password: data.password,
|
||||
fullName: data.fullName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const response = await fetch("/connect/signup-from-invitation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
password: data.password,
|
||||
fullName: data.fullName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -78,7 +74,9 @@ export default function SignupFromInvitationPage() {
|
||||
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Account created successfully. Please accept your invitation to join the organization."),
|
||||
description: __(
|
||||
"Account created successfully. Please accept your invitation to join the organization."
|
||||
),
|
||||
variant: "success",
|
||||
});
|
||||
navigate("/", { replace: true });
|
||||
|
||||
Reference in New Issue
Block a user