Compare commits
172 Commits
@nhost/has
...
@nhost/str
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21fb316655 | ||
|
|
9c4f350508 | ||
|
|
eb3ba21afc | ||
|
|
a84ac62999 | ||
|
|
f32bfed9a9 | ||
|
|
55632506e4 | ||
|
|
e146d32e69 | ||
|
|
df8a13997c | ||
|
|
c488fd4c0c | ||
|
|
a6e8569822 | ||
|
|
a8023c9a3f | ||
|
|
59347fcd4b | ||
|
|
5b65cac91e | ||
|
|
331ae02e2d | ||
|
|
3b8b3be393 | ||
|
|
9fb4d82d86 | ||
|
|
37d836b9b6 | ||
|
|
3c1996b13b | ||
|
|
e3d90fd5d2 | ||
|
|
af016e1caa | ||
|
|
963f9b5e85 | ||
|
|
ed4c780115 | ||
|
|
260997c6fe | ||
|
|
941f0f5755 | ||
|
|
75344b2bc0 | ||
|
|
65da426e8b | ||
|
|
a711727e94 | ||
|
|
8e8f6fd9c9 | ||
|
|
a1c2e5d8ee | ||
|
|
5c276ae844 | ||
|
|
f34702f3c5 | ||
|
|
6cb70eee01 | ||
|
|
9395c9687f | ||
|
|
eb1eb934a4 | ||
|
|
c62fed2c9a | ||
|
|
16fe1a47da | ||
|
|
0f04e8b8b8 | ||
|
|
e6dad4d696 | ||
|
|
bcb3b79add | ||
|
|
fe658231b4 | ||
|
|
a1188b7d98 | ||
|
|
cd4bdc581d | ||
|
|
4e2f8ccd52 | ||
|
|
8a6d8c7534 | ||
|
|
fa75409f09 | ||
|
|
74662052ae | ||
|
|
2de904c865 | ||
|
|
37ab5fe878 | ||
|
|
be9af96fa7 | ||
|
|
31abbe5f30 | ||
|
|
268b461d5b | ||
|
|
58af592cfa | ||
|
|
0e9d623c69 | ||
|
|
412a290646 | ||
|
|
123add38a4 | ||
|
|
5bdd31ad36 | ||
|
|
5121851c8b | ||
|
|
8ca1f92491 | ||
|
|
5535b9085b | ||
|
|
bc51122b25 | ||
|
|
b060e5e550 | ||
|
|
6a906b22e2 | ||
|
|
860c9d1be4 | ||
|
|
9eec3e58f5 | ||
|
|
4e01a43e94 | ||
|
|
c126b20dcf | ||
|
|
b727a24a5f | ||
|
|
ecadd7e1b9 | ||
|
|
2d661174a8 | ||
|
|
fcb3e5192f | ||
|
|
66fdc63f38 | ||
|
|
fa37cb6171 | ||
|
|
c1bea1294d | ||
|
|
8af2f6e9dd | ||
|
|
e3d0b96917 | ||
|
|
43705b992d | ||
|
|
2e999e8715 | ||
|
|
0370696d5c | ||
|
|
f62131d55a | ||
|
|
36c3519cf8 | ||
|
|
86d077ac00 | ||
|
|
a21aa05b5a | ||
|
|
200e9f774c | ||
|
|
9b52e9bf13 | ||
|
|
bc1235de3b | ||
|
|
fce58ebaea | ||
|
|
452e281120 | ||
|
|
9a338e54c9 | ||
|
|
baeebf980d | ||
|
|
ac92c6ee61 | ||
|
|
1ddaf680c0 | ||
|
|
c6e6194d8e | ||
|
|
83deea8b45 | ||
|
|
07c8d90053 | ||
|
|
acbaabcf85 | ||
|
|
a2621e40a4 | ||
|
|
3534501f37 | ||
|
|
27bc23cbbc | ||
|
|
61120a137a | ||
|
|
faea8feb2e | ||
|
|
6450223558 | ||
|
|
a62a85a777 | ||
|
|
ae24f83953 | ||
|
|
fc60d7a782 | ||
|
|
6be8a998df | ||
|
|
ea091f6251 | ||
|
|
8175c052f7 | ||
|
|
e6605a6ed0 | ||
|
|
1cba0e6492 | ||
|
|
179c90fcdb | ||
|
|
552e31a4f0 | ||
|
|
85f0f943a1 | ||
|
|
c4c23fde31 | ||
|
|
e0b94c3e90 | ||
|
|
113d638532 | ||
|
|
d87448916f | ||
|
|
af4292658c | ||
|
|
f735bcd2ea | ||
|
|
66fb74af86 | ||
|
|
791eac30bb | ||
|
|
da4ad889d7 | ||
|
|
9ef111760c | ||
|
|
c2706c7d97 | ||
|
|
683b8768c4 | ||
|
|
6d9df237a8 | ||
|
|
220ae37aa7 | ||
|
|
d0d94d9239 | ||
|
|
aed3d1f147 | ||
|
|
d07bf08e45 | ||
|
|
f2183250d2 | ||
|
|
d2bb5ecfae | ||
|
|
02d0db0cf0 | ||
|
|
441005d5c3 | ||
|
|
eea8708549 | ||
|
|
5f3f9390aa | ||
|
|
1c5b0560ed | ||
|
|
1bfdf21b99 | ||
|
|
c4561cae38 | ||
|
|
efd522a38a | ||
|
|
55c35fa9c5 | ||
|
|
d42c27ae99 | ||
|
|
927be4a2c9 | ||
|
|
e44352abbd | ||
|
|
f9289f3c32 | ||
|
|
8ff06e5637 | ||
|
|
49e4633bca | ||
|
|
7ae7a7206c | ||
|
|
43d7e7babf | ||
|
|
463a51ce7c | ||
|
|
86e9d9d47f | ||
|
|
f99b72cd7c | ||
|
|
0dc2f3ff29 | ||
|
|
d0f8081101 | ||
|
|
84ebfb79d0 | ||
|
|
3c78d0ef46 | ||
|
|
ad28bf2166 | ||
|
|
dbd3ded515 | ||
|
|
5399fac211 | ||
|
|
52e3127a34 | ||
|
|
599387934c | ||
|
|
04cea41111 | ||
|
|
dc3723306d | ||
|
|
d7fa572ab6 | ||
|
|
a529b654bc | ||
|
|
c21118257f | ||
|
|
4712b7ff68 | ||
|
|
4f305a8985 | ||
|
|
cd7d133ba3 | ||
|
|
2927a9ac31 | ||
|
|
695eaa77ca | ||
|
|
a29d21e194 | ||
|
|
cd20bd4ef2 |
@@ -2,20 +2,7 @@
|
||||
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"linked": [
|
||||
[
|
||||
"@nhost/nextjs",
|
||||
"@nhost/react",
|
||||
"@nhost/vue",
|
||||
"@nhost/nhost-js",
|
||||
"@nhost/hasura-auth-js",
|
||||
"@nhost/hasura-storage-js"
|
||||
],
|
||||
[
|
||||
"@nhost/react-apollo",
|
||||
"@nhost/apollo"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
|
||||
2
.github/actions/nhost-cli/action.yaml
vendored
2
.github/actions/nhost-cli/action.yaml
vendored
@@ -38,7 +38,7 @@ runs:
|
||||
uses: nick-fields/retry@v2
|
||||
with:
|
||||
timeout_minutes: 3
|
||||
max_attempts: 3
|
||||
max_attempts: 10
|
||||
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
||||
- name: Set custom configuration
|
||||
if: ${{ inputs.config }}
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e146d32e: chore(deps): update dependency @types/react to v18.0.27
|
||||
- 59347fcd: correct allowed role name
|
||||
- 5b65cac9: updated authentication documentation
|
||||
- 963f9b5e: feat(dashboard): include project info in feedback
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ed4c7801: chore(dashboard): remove Functions section
|
||||
|
||||
## 0.9.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4e2f8ccd: fix(dashboard): don't break Auth page in local mode
|
||||
|
||||
## 0.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 31abbe5f: fix(dashboard): enable toggle when settings are filled in
|
||||
|
||||
## 0.9.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5bdd31ad: chore(dashboard): list fewer images per page on the Storage page
|
||||
- 5121851c: fix(dashboard): don't throw validation error for valid permission rules
|
||||
|
||||
## 0.9.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c126b20d: fix(dashboard): correct redeployment button
|
||||
|
||||
## 0.9.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 36c3519c: feat(dashboard): retrigger deployments
|
||||
|
||||
## 0.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
|
||||
- Updated dependencies [200e9f77]
|
||||
- @nhost/nextjs@1.13.2
|
||||
- @nhost/react-apollo@4.13.2
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dbd3ded5: fix(dashboard): workspaces creation, new form, correct redirects.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 85f0f943: fix(dashboard): don't break the table creation process
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d42c27ae]
|
||||
- Updated dependencies [927be4a2]
|
||||
- @nhost/nextjs@1.13.1
|
||||
- @nhost/react-apollo@4.13.1
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d0f80811: fix(dashboard): don't show error when signing out the user
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
RUN yarn global add turbo@1
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Nhost Dashboard
|
||||
|
||||
This is the Nhost Dashboard, a web application that allows you to manage your Nhost project.
|
||||
This is the Nhost Dashboard, a web application that allows you to manage your Nhost projects.
|
||||
To get started, you need to have an Nhost project. If you don't have one, you can [create a project here](https://app.nhost.io).
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "next build --no-lint",
|
||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||
"start": "next start",
|
||||
"lint": "next lint --max-warnings 3",
|
||||
"lint": "next lint --max-warnings 2",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"nhost:dev": "nhost dev -d",
|
||||
@@ -104,15 +104,15 @@
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.0.25",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitest/coverage-c8": "^0.26.0",
|
||||
"@vitest/coverage-c8": "^0.27.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
@@ -126,7 +126,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.6.1",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^20.0.3",
|
||||
"jsdom": "^21.0.0",
|
||||
"lint-staged": ">=13",
|
||||
"msw": "^0.49.0",
|
||||
"msw-storybook-addon": "^1.6.3",
|
||||
@@ -143,7 +143,7 @@
|
||||
"typescript": "^4.8.4",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.26.2",
|
||||
"vitest": "^0.27.0",
|
||||
"webpack": "^5.75.0"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { DeploymentRowFragment } from '@/generated/graphql';
|
||||
import { useGetDeploymentsSubSubscription } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import DelayedLoading from '@/ui/DelayedLoading';
|
||||
import Status, { StatusEnum } from '@/ui/Status';
|
||||
import type { DeploymentStatus } from '@/ui/StatusCircle';
|
||||
import { StatusCircle } from '@/ui/StatusCircle';
|
||||
import { getLastLiveDeployment } from '@/utils/helpers';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
formatDistanceToNowStrict,
|
||||
parseISO,
|
||||
} from 'date-fns';
|
||||
useGetDeploymentsSubSubscription,
|
||||
useLatestLiveDeploymentSubSubscription,
|
||||
useScheduledOrPendingDeploymentsSubSubscription,
|
||||
} from '@/generated/graphql';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type AppDeploymentsProps = {
|
||||
appId: string;
|
||||
@@ -66,146 +59,8 @@ function NextPrevPageLink(props: NextPrevPageLinkProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type AppDeploymentDurationProps = {
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
};
|
||||
|
||||
export function AppDeploymentDuration({
|
||||
startedAt,
|
||||
endedAt,
|
||||
}: AppDeploymentDurationProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (!endedAt) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [endedAt]);
|
||||
|
||||
const totalDurationInSeconds = differenceInSeconds(
|
||||
endedAt ? parseISO(endedAt) : currentTime,
|
||||
parseISO(startedAt),
|
||||
);
|
||||
|
||||
if (totalDurationInSeconds > 1200) {
|
||||
return <div>20+m</div>;
|
||||
}
|
||||
|
||||
const durationMins = Math.floor(totalDurationInSeconds / 60);
|
||||
const durationSecs = totalDurationInSeconds % 60;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
className="self-center font-display text-sm+ text-greyscaleDark"
|
||||
>
|
||||
{durationMins}m {durationSecs}s
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AppDeploymentRowProps = {
|
||||
deployment: DeploymentRowFragment;
|
||||
isDeploymentLive: boolean;
|
||||
};
|
||||
|
||||
export function AppDeploymentRow({
|
||||
deployment,
|
||||
isDeploymentLive,
|
||||
}: AppDeploymentRowProps) {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { commitMessage } = deployment;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center px-2 py-4">
|
||||
<div className="mr-2 flex items-center justify-center">
|
||||
<Avatar
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-4 w-full">
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
passHref
|
||||
>
|
||||
<a
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
>
|
||||
<div className="max-w-md truncate text-sm+ font-normal text-greyscaleDark">
|
||||
{commitMessage?.trim() || (
|
||||
<span className="pr-1 font-normal italic">
|
||||
No commit message
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm+ text-greyscaleGrey">
|
||||
{formatDistanceToNowStrict(
|
||||
parseISO(deployment.deploymentStartedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
{isDeploymentLive && (
|
||||
<div className="flex self-center align-middle">
|
||||
<Status status={StatusEnum.Live}>Live</Status>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-28 self-center text-right font-mono text-sm- font-medium">
|
||||
<a
|
||||
className="font-mono font-medium text-greyscaleDark"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`https://github.com/${currentApplication.githubRepository?.fullName}/commit/${deployment.commitSHA}`}
|
||||
>
|
||||
{deployment.commitSHA.substring(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
<div className="mx-4 w-28 text-right">
|
||||
<AppDeploymentDuration
|
||||
startedAt={deployment.deploymentStartedAt}
|
||||
endedAt={deployment.deploymentEndedAt}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-3 self-center">
|
||||
<StatusCircle
|
||||
status={deployment.deploymentStatus as DeploymentStatus}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
passHref
|
||||
>
|
||||
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppDeployments(props: AppDeploymentsProps) {
|
||||
const { appId } = props;
|
||||
const [idOfLiveDeployment, setIdOfLiveDeployment] = useState('');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -216,36 +71,59 @@ export default function AppDeployments(props: AppDeploymentsProps) {
|
||||
const limit = 10;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// @TODO: Should query for all deployments, then subscribe to new ones.
|
||||
|
||||
const { data, loading, error } = useGetDeploymentsSubSubscription({
|
||||
variables: {
|
||||
id: appId,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
const {
|
||||
data: deploymentPageData,
|
||||
loading: deploymentPageLoading,
|
||||
error,
|
||||
} = useGetDeploymentsSubSubscription({
|
||||
variables: { id: appId, limit, offset },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const { data: latestDeploymentData, loading: latestDeploymentLoading } =
|
||||
useGetDeploymentsSubSubscription({
|
||||
variables: { id: appId, limit: 1, offset: 0 },
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
setIdOfLiveDeployment(getLastLiveDeployment(data.deployments));
|
||||
}
|
||||
}, [data, idOfLiveDeployment, loading, page]);
|
||||
const {
|
||||
data: latestLiveDeploymentData,
|
||||
loading: latestLiveDeploymentLoading,
|
||||
} = useLatestLiveDeploymentSubSubscription({ variables: { appId } });
|
||||
|
||||
const {
|
||||
data: scheduledOrPendingDeploymentsData,
|
||||
loading: scheduledOrPendingDeploymentsLoading,
|
||||
} = useScheduledOrPendingDeploymentsSubSubscription({ variables: { appId } });
|
||||
|
||||
const loading =
|
||||
deploymentPageLoading ||
|
||||
scheduledOrPendingDeploymentsLoading ||
|
||||
latestDeploymentLoading ||
|
||||
latestLiveDeploymentLoading;
|
||||
|
||||
if (loading) {
|
||||
return <DelayedLoading delay={500} className="mt-12" />;
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="mt-12"
|
||||
label="Loading deployments..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nrOfDeployments = data.deployments.length;
|
||||
const { deployments } = deploymentPageData || {};
|
||||
const { deployments: scheduledOrPendingDeployments } =
|
||||
scheduledOrPendingDeploymentsData || {};
|
||||
|
||||
const latestDeployment = latestDeploymentData?.deployments[0];
|
||||
const latestLiveDeployment = latestLiveDeploymentData?.deployments[0];
|
||||
|
||||
const nrOfDeployments = deployments?.length || 0;
|
||||
const nextAllowed = !(nrOfDeployments < limit);
|
||||
const liveDeploymentId = latestLiveDeployment?.id || '';
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
@@ -253,15 +131,17 @@ export default function AppDeployments(props: AppDeploymentsProps) {
|
||||
<p className="text-sm text-greyscaleGrey">No deployments yet.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mt-3 divide-y-1 border-t border-b">
|
||||
{data.deployments.map((deployment) => (
|
||||
<AppDeploymentRow
|
||||
deployment={deployment}
|
||||
<List className="mt-3 divide-y-1 border-t border-b">
|
||||
{deployments.map((deployment) => (
|
||||
<DeploymentListItem
|
||||
key={deployment.id}
|
||||
isDeploymentLive={idOfLiveDeployment === deployment.id}
|
||||
deployment={deployment}
|
||||
isLive={liveDeploymentId === deployment.id}
|
||||
showRedeploy={latestDeployment.id === deployment.id}
|
||||
disableRedeploy={scheduledOrPendingDeployments.length > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
<div className="mt-8 flex w-full justify-center">
|
||||
<div className="flex items-center">
|
||||
<NextPrevPageLink
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { inputErrorMessages } from '@/utils/getErrorMessage';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useUpdateWorkspaceMutation } from '@/utils/__generated__/graphql';
|
||||
import router from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
type ChangeWorkspaceNameProps = {
|
||||
close: VoidFunction;
|
||||
};
|
||||
|
||||
export default function ChangeWorkspaceName({
|
||||
close,
|
||||
}: ChangeWorkspaceNameProps) {
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
|
||||
const [newWorkspaceName, setNewWorkspaceName] = useState(
|
||||
currentWorkspace.name,
|
||||
);
|
||||
const [workspaceError, setWorkspaceError] = useState<string>('');
|
||||
|
||||
const [updateWorkspace, { loading: mutationLoading, error: mutationError }] =
|
||||
useUpdateWorkspaceMutation({
|
||||
refetchQueries: [],
|
||||
});
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
inputErrorMessages(
|
||||
event.target.value,
|
||||
setNewWorkspaceName,
|
||||
setWorkspaceError,
|
||||
'Workspace',
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = newWorkspaceName;
|
||||
const slug = slugifyString(name);
|
||||
|
||||
if (slug.length < 4 || slug.length > 32) {
|
||||
setWorkspaceError('Slug should be within 4 and 32 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateWorkspace({
|
||||
variables: {
|
||||
id: currentWorkspace.id,
|
||||
workspace: {
|
||||
name,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
close();
|
||||
triggerToast('Workspace name changed');
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
`Error trying to remove workspace: ${currentWorkspace.id} - ${error.message}`,
|
||||
);
|
||||
}
|
||||
await router.push(slug);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-modal px-6 py-6 text-left">
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h3" component="h2">
|
||||
Change Workspace Name
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4 grid grid-flow-row gap-2">
|
||||
<Input
|
||||
id="workspaceName"
|
||||
label="New Workspace Name"
|
||||
onChange={handleChange}
|
||||
value={newWorkspaceName}
|
||||
placeholder="New workspace name"
|
||||
fullWidth
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
helperText={`https://app.nhost.io/${slugifyString(
|
||||
newWorkspaceName || '',
|
||||
)}`}
|
||||
/>
|
||||
|
||||
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
|
||||
|
||||
{mutationError && (
|
||||
<Alert severity="error">{mutationError.toString()}</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-flow-row gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutationLoading || !!workspaceError}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type FunctionLog = {
|
||||
name: string;
|
||||
language: string;
|
||||
logs: { date: string; message: string; createdAt: string }[];
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import { formatDistance } from 'date-fns';
|
||||
|
||||
export interface FunctionLogDataEntryProps {
|
||||
time: string;
|
||||
nav: string;
|
||||
}
|
||||
|
||||
export function FunctionLogDataEntry({ time, nav }: FunctionLogDataEntryProps) {
|
||||
return (
|
||||
<a href={`#${nav}`}>
|
||||
<div className="flex cursor-pointer flex-row place-content-between border-t py-3">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className="flex font-medium"
|
||||
size="tiny"
|
||||
>
|
||||
{formatDistance(new Date(time), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center text-greyscaleDark" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunctionLogDataEntry;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { FunctionLogDataEntry } from './FunctionLogDataEntry';
|
||||
|
||||
export interface FunctionLogHistoryProps {
|
||||
logs?: Log[];
|
||||
}
|
||||
|
||||
type Log = {
|
||||
createdAt: string;
|
||||
date: any;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function FunctionLogHistory({ logs }: FunctionLogHistoryProps) {
|
||||
return (
|
||||
<div className=" mx-auto max-w-6xl pt-10">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<div className="flex">
|
||||
<Text size="large" className="font-medium" color="greyscaleDark">
|
||||
Log History
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
<Text className="font-semibold" size="normal" color="greyscaleDark">
|
||||
Time
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{logs ? (
|
||||
<div>
|
||||
{logs.slice(0, 4).map((log: Log) => (
|
||||
<FunctionLogDataEntry
|
||||
time={log.createdAt}
|
||||
nav={`#-${log.date}`}
|
||||
key={`${log.date}-${log.message.slice(66)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-1 pl-0.5 font-mono text-xs text-greyscaleDark">
|
||||
No log history.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunctionLogHistory;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { normalizeToIndividualFunctionsWithLogs } from '@/components/applications/functions/normalizeToIndividualFunctionsWithLogs';
|
||||
import terminalTheme from '@/data/terminalTheme';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useGetFunctionLogQuery } from '@/utils/__generated__/graphql';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
||||
import { FunctionLogHistory } from './FunctionLogHistory';
|
||||
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
|
||||
export function FunctionsLogsTerminalPage({ functionName }: any) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
|
||||
|
||||
const { data, startPolling } = useGetFunctionLogQuery({
|
||||
variables: {
|
||||
subdomain: currentApplication.subdomain,
|
||||
functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(3000);
|
||||
}, [startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.getFunctionLogs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNormalizedFunctionData(
|
||||
normalizeToIndividualFunctionsWithLogs(data.getFunctionLogs)[0],
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
if (
|
||||
!data ||
|
||||
data.getFunctionLogs.length === 0 ||
|
||||
!normalizedFunctionData ||
|
||||
normalizedFunctionData.logs.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="w-full rounded-lg text-white">
|
||||
<div className="h-terminal overflow-auto rounded-lg bg-log px-4 py-4 font-mono shadow-sm">
|
||||
<div className="font-mono text-xs text-grey">
|
||||
There are no stored logs yet. Try calling your function for logs to
|
||||
appear.
|
||||
</div>
|
||||
</div>
|
||||
<FunctionLogHistory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full rounded-lg text-white">
|
||||
<div className="h-terminal overflow-auto rounded-lg bg-log px-4 py-4 font-mono shadow-sm">
|
||||
{normalizedFunctionData.logs.map((log) => (
|
||||
<div
|
||||
key={`${log.date}-${log.message.slice(66)}`}
|
||||
className=" flex text-sm"
|
||||
>
|
||||
<div id={`#-${log.date}`}>
|
||||
<pre className="inline">
|
||||
<span className="mr-4 text-greyscaleGrey">{log.date}</span>{' '}
|
||||
<span className="">
|
||||
{' '}
|
||||
<SyntaxHighlighter
|
||||
style={terminalTheme}
|
||||
customStyle={{
|
||||
display: 'inline',
|
||||
}}
|
||||
className="inline-flex"
|
||||
language="json"
|
||||
>
|
||||
{log.message}
|
||||
</SyntaxHighlighter>
|
||||
</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FunctionLogHistory logs={normalizedFunctionData.logs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunctionsLogsTerminalPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
export type FunctionResponseLog = {
|
||||
functionPath: string;
|
||||
createdAt: string;
|
||||
message: string;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import Loading from '@/ui/Loading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function FunctionsNotDeployed() {
|
||||
return (
|
||||
<div className="mx-auto mt-12 max-w-2xl text-center">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/terminal-text.svg"
|
||||
alt="Terminal with a green dot"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
</div>
|
||||
<Text className="mt-4 font-medium" size="large" color="dark">
|
||||
Functions Logs
|
||||
</Text>
|
||||
<Text size="normal" color="greyscaleDark" className="mt-1 transform">
|
||||
Once you deploy a function, you can view the logs here.
|
||||
</Text>
|
||||
<div className="mt-1.5 flex text-center">
|
||||
<Button
|
||||
Component="a"
|
||||
transparent
|
||||
color="blue"
|
||||
className="mx-auto cursor-pointer font-medium"
|
||||
href="https://docs.nhost.io/platform/serverless-functions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-24 flex flex-col text-center">
|
||||
<Loading />
|
||||
<Text size="normal" color="greyscaleDark" className="mt-1 transform">
|
||||
Awaiting new requests…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunctionsNotDeployed;
|
||||
@@ -1,79 +0,0 @@
|
||||
export type FinalFunction = {
|
||||
folder: string;
|
||||
funcs: Func[];
|
||||
nestedLevel: number;
|
||||
parentFolder?: string;
|
||||
};
|
||||
|
||||
export type Func = {
|
||||
name: string;
|
||||
id: string;
|
||||
lang: string;
|
||||
functionName: string;
|
||||
route?: string;
|
||||
path?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdWithCommitSha?: string;
|
||||
formattedCreatedAt?: string;
|
||||
formattedUpdatedAt?: string;
|
||||
};
|
||||
|
||||
export const normalizeFunctionMetadata = (functions): FinalFunction[] => {
|
||||
const finalFunctions: FinalFunction[] = [
|
||||
{ folder: 'functions', funcs: [], nestedLevel: 0 },
|
||||
];
|
||||
const topLevelFunctionsFolder = finalFunctions[0].funcs;
|
||||
functions.forEach((func) => {
|
||||
const nestedLevel = func.path?.split('/').length;
|
||||
const newFuncToAdd = {
|
||||
...func,
|
||||
name: func.path?.split('/')[nestedLevel - 1],
|
||||
lang: func.path?.split('.')[1],
|
||||
// formattedCreatedAt: `${format(
|
||||
// parseISO(func.createdAt),
|
||||
// 'yyyy-MM-dd HH:mm:ss',
|
||||
// )}`,
|
||||
// formattedUpdatedAt: `${formatDistanceToNowStrict(
|
||||
// parseISO(func.updatedAt),
|
||||
// {
|
||||
// addSuffix: true,
|
||||
// },
|
||||
// )}`,
|
||||
};
|
||||
|
||||
if (nestedLevel === 2) {
|
||||
topLevelFunctionsFolder.push(newFuncToAdd);
|
||||
} else if (nestedLevel > 2) {
|
||||
const nameOfTheFolder = func.path?.split('/')[nestedLevel - 2];
|
||||
const nameOfParentFolder = func.path?.split('/')[nestedLevel - 3];
|
||||
const checkForFolderExistence = finalFunctions.find(
|
||||
(functionFolder) => functionFolder.folder === nameOfTheFolder,
|
||||
);
|
||||
|
||||
if (!checkForFolderExistence) {
|
||||
finalFunctions.push({
|
||||
folder: nameOfTheFolder,
|
||||
funcs: [newFuncToAdd],
|
||||
nestedLevel: nestedLevel - 2,
|
||||
parentFolder: nameOfParentFolder,
|
||||
});
|
||||
} else {
|
||||
checkForFolderExistence.funcs.push(newFuncToAdd);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Sort folders by putting the subfolder next to their parent folder, even though they share the same place in the array
|
||||
// except for the nestedLevel prop. A future change to this would be to make folders have subfolders, which is easier
|
||||
// understand, but would require a change in the UI.
|
||||
// @TODO: Change to have elements have subfolders inside the object?
|
||||
finalFunctions.sort((a, b) => {
|
||||
if (a.folder === b.parentFolder) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
});
|
||||
|
||||
return finalFunctions;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { FunctionLog } from './FunctionLog';
|
||||
import type { FunctionResponseLog } from './FunctionResponseLog';
|
||||
|
||||
export const normalizeToIndividualFunctionsWithLogs = (
|
||||
functionLogs: FunctionResponseLog[],
|
||||
) => {
|
||||
const arrayOfFunctions: FunctionLog[] = [];
|
||||
const sortedFunctions = [...functionLogs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
sortedFunctions.forEach((functionLog) => {
|
||||
const funcName = functionLog.functionPath;
|
||||
const logMessage = {
|
||||
createdAt: functionLog.createdAt,
|
||||
date: `${format(parseISO(functionLog.createdAt), 'yyyy-MM-dd HH:mm:ss')}`,
|
||||
message: functionLog.message,
|
||||
};
|
||||
const newFunc = {
|
||||
name: funcName,
|
||||
language: functionLog.functionPath.split('.')[1],
|
||||
logs: [logMessage],
|
||||
};
|
||||
// If the function is already in the array of functions to log, just add the new log message to the existing object...
|
||||
if (arrayOfFunctions.some((obj) => obj.name === funcName)) {
|
||||
const index = arrayOfFunctions.findIndex((obj) => obj.name === funcName);
|
||||
const currentFunction = arrayOfFunctions[index];
|
||||
currentFunction.logs.push(logMessage);
|
||||
} else {
|
||||
// If the function is not in the array of functions, add it with the log message to it.
|
||||
arrayOfFunctions.push(newFunc);
|
||||
}
|
||||
});
|
||||
|
||||
return arrayOfFunctions;
|
||||
};
|
||||
|
||||
export default normalizeToIndividualFunctionsWithLogs;
|
||||
@@ -34,13 +34,13 @@ export function Repo({ repo, setSelectedRepoId }: RepoProps) {
|
||||
const [updateApp, { loading, error }] = useUpdateAppMutation({
|
||||
refetchQueries: [
|
||||
refetchGetAppByWorkspaceAndNameQuery({
|
||||
workspace: currentWorkspace.slug,
|
||||
slug: currentApplication.slug,
|
||||
workspace: currentWorkspace?.slug,
|
||||
slug: currentApplication?.slug,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { githubRepository } = currentApplication;
|
||||
const { githubRepository } = currentApplication || {};
|
||||
|
||||
const isThisRepositoryAlreadyConnected =
|
||||
githubRepository?.fullName && githubRepository.fullName === repo.fullName;
|
||||
|
||||
@@ -52,9 +52,9 @@ function ControlledAutocomplete(
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
inputValue={typeof field.value === 'string' ? field.value : undefined}
|
||||
{...props}
|
||||
{...field}
|
||||
inputValue={typeof field.value === 'string' ? field.value : undefined}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
onChange={(event, options, reason, details) => {
|
||||
setValue?.(controllerProps?.name || name, options, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createContext } from 'react';
|
||||
* Available dialog types.
|
||||
*/
|
||||
export type DialogType =
|
||||
| 'EDIT_WORKSPACE_NAME'
|
||||
| 'CREATE_RECORD'
|
||||
| 'CREATE_COLUMN'
|
||||
| 'EDIT_COLUMN'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
|
||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||
@@ -366,6 +367,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<RetryableErrorBoundary
|
||||
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
||||
>
|
||||
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
|
||||
<EditWorkspaceNameForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
||||
<CreateForeignKeyForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { ChangePasswordModal } from '@/components/applications/ChangePasswordModal';
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import { useUserDataContext } from '@/context/workspace1-context';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import { Dropdown, useDropdown } from '@/ui/v2/Dropdown';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { emptyWorkspace } from '@/utils/helpers';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -22,9 +20,8 @@ function AccountMenuContent({
|
||||
}: AccountMenuContentProps) {
|
||||
const user = useUserData();
|
||||
const router = useRouter();
|
||||
const client = useApolloClient();
|
||||
const [clicked, setClicked] = useState(false);
|
||||
const { setWorkspaceContext } = useWorkspaceContext();
|
||||
const { setUserContext } = useUserDataContext();
|
||||
const { handleClose } = useDropdown();
|
||||
|
||||
return (
|
||||
@@ -34,10 +31,9 @@ function AccountMenuContent({
|
||||
color="secondary"
|
||||
className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium"
|
||||
onClick={async () => {
|
||||
setWorkspaceContext(emptyWorkspace());
|
||||
setUserContext({ workspaces: [] });
|
||||
nhost.auth.signOut();
|
||||
router.push('/signin');
|
||||
await nhost.auth.signOut();
|
||||
await client.resetStore();
|
||||
}}
|
||||
aria-label="Sign Out"
|
||||
>
|
||||
|
||||
@@ -118,6 +118,7 @@ export default function BaseColumnForm({
|
||||
variant="inline"
|
||||
className="col-span-8 py-3"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<ControlledAutocomplete
|
||||
@@ -272,6 +273,7 @@ export default function BaseColumnForm({
|
||||
error={Boolean(errors.comment)}
|
||||
variant="inline"
|
||||
className="col-span-8 py-3"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@ function NameInput() {
|
||||
error={Boolean(errors.name)}
|
||||
variant="inline"
|
||||
className="col-span-8 py-3"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ function NameInput({ index }: FieldArrayInputProps) {
|
||||
}
|
||||
},
|
||||
})}
|
||||
autoComplete="off"
|
||||
aria-label="Name"
|
||||
placeholder="Enter name"
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -13,6 +13,7 @@ import Option from '@/ui/v2/Option';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||
|
||||
@@ -41,6 +42,7 @@ export default function ColumnPresetsSection({
|
||||
table,
|
||||
disabled,
|
||||
}: ColumnPresetSectionProps) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
data: tableData,
|
||||
status: tableStatus,
|
||||
@@ -131,7 +133,12 @@ export default function ColumnPresetsSection({
|
||||
freeSolo
|
||||
fullWidth
|
||||
disableClearable={false}
|
||||
clearIcon={<XIcon />}
|
||||
clearIcon={
|
||||
<XIcon
|
||||
className="w-4 h-4 mt-px"
|
||||
sx={{ color: theme.palette.text.primary }}
|
||||
/>
|
||||
}
|
||||
autoSelect
|
||||
autoHighlight={false}
|
||||
error={Boolean(
|
||||
|
||||
@@ -4,7 +4,17 @@ import * as Yup from 'yup';
|
||||
const ruleSchema = Yup.object().shape({
|
||||
column: Yup.string().nullable().required('Please select a column.'),
|
||||
operator: Yup.string().nullable().required('Please select an operator.'),
|
||||
value: Yup.string().nullable().required('Please enter a value.'),
|
||||
value: Yup.mixed()
|
||||
.test(
|
||||
'isArray',
|
||||
'Please enter a valid value.',
|
||||
(value) =>
|
||||
typeof value === 'string' ||
|
||||
(Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string')),
|
||||
)
|
||||
.nullable()
|
||||
.required('Please enter a value.'),
|
||||
});
|
||||
|
||||
const ruleGroupSchema = Yup.object().shape({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { differenceInSeconds, parseISO } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface AppDeploymentDurationProps {
|
||||
/**
|
||||
* Start date of the deployment.
|
||||
*/
|
||||
startedAt: string;
|
||||
/**
|
||||
* End date of the deployment.
|
||||
*/
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export default function AppDeploymentDuration({
|
||||
startedAt,
|
||||
endedAt,
|
||||
}: AppDeploymentDurationProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (!endedAt) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [endedAt]);
|
||||
|
||||
const totalDurationInSeconds = differenceInSeconds(
|
||||
endedAt ? parseISO(endedAt) : currentTime,
|
||||
parseISO(startedAt),
|
||||
);
|
||||
|
||||
if (totalDurationInSeconds > 1200) {
|
||||
return <div>20+m</div>;
|
||||
}
|
||||
|
||||
const durationMins = Math.floor(totalDurationInSeconds / 60);
|
||||
const durationSecs = totalDurationInSeconds % 60;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
className="self-center font-display text-sm+ text-greyscaleDark"
|
||||
>
|
||||
{Number.isNaN(durationMins) || Number.isNaN(durationSecs) ? (
|
||||
<span>0m 0s</span>
|
||||
) : (
|
||||
<span>
|
||||
{durationMins}m {durationSecs}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AppDeploymentDuration';
|
||||
export { default } from './AppDeploymentDuration';
|
||||
@@ -0,0 +1,161 @@
|
||||
import NavLink from '@/components/common/NavLink';
|
||||
import AppDeploymentDuration from '@/components/deployments/AppDeploymentDuration';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import Status, { StatusEnum } from '@/ui/Status';
|
||||
import type { DeploymentStatus } from '@/ui/StatusCircle';
|
||||
import { StatusCircle } from '@/ui/StatusCircle';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
|
||||
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeploymentListItemProps {
|
||||
/**
|
||||
* Deployment data.
|
||||
*/
|
||||
deployment: DeploymentRowFragment;
|
||||
/**
|
||||
* Determines whether or not the deployment is live.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
/**
|
||||
* Determines whether or not the redeploy button should be shown for the
|
||||
* deployment.
|
||||
*/
|
||||
showRedeploy?: boolean;
|
||||
/**
|
||||
* Determines whether or not the redeploy button is disabled.
|
||||
*/
|
||||
disableRedeploy?: boolean;
|
||||
}
|
||||
|
||||
export default function DeploymentListItem({
|
||||
deployment,
|
||||
isLive,
|
||||
showRedeploy,
|
||||
disableRedeploy,
|
||||
}: DeploymentListItemProps) {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
|
||||
const relativeDateOfDeployment = deployment.deploymentStartedAt
|
||||
? formatDistanceToNowStrict(parseISO(deployment.deploymentStartedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: '';
|
||||
|
||||
const [insertDeployment, { loading }] = useInsertDeploymentMutation();
|
||||
const { commitMessage } = deployment;
|
||||
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
className="grid grid-flow-col items-center justify-between gap-2 px-2 py-2"
|
||||
component={NavLink}
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center">
|
||||
<ListItem.Avatar>
|
||||
<Avatar
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
</ListItem.Avatar>
|
||||
|
||||
<ListItem.Text
|
||||
primary={
|
||||
commitMessage?.trim() || (
|
||||
<span className="truncate pr-1 font-normal italic">
|
||||
No commit message
|
||||
</span>
|
||||
)
|
||||
}
|
||||
secondary={relativeDateOfDeployment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-col gap-2 items-center">
|
||||
{showRedeploy && (
|
||||
<Tooltip
|
||||
title="Deployments cannot be re-triggered when a deployment is in progress."
|
||||
hasDisabledChildren={disableRedeploy || loading}
|
||||
disableHoverListener={!disableRedeploy}
|
||||
>
|
||||
<Button
|
||||
disabled={disableRedeploy || loading}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const insertDeploymentPromise = insertDeployment({
|
||||
variables: {
|
||||
object: {
|
||||
appId: currentApplication?.id,
|
||||
commitMessage: deployment.commitMessage,
|
||||
commitSHA: deployment.commitSHA,
|
||||
commitUserAvatarUrl: deployment.commitUserAvatarUrl,
|
||||
commitUserName: deployment.commitUserName,
|
||||
deploymentStatus: 'SCHEDULED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
insertDeploymentPromise,
|
||||
{
|
||||
loading: 'Scheduling deployment...',
|
||||
success: 'Deployment has been scheduled successfully.',
|
||||
error: 'An error occurred when scheduling deployment.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
}}
|
||||
startIcon={
|
||||
<ArrowCounterclockwiseIcon className={twMerge('w-4 h-4')} />
|
||||
}
|
||||
className="rounded-full py-1 px-2 text-xs"
|
||||
>
|
||||
Redeploy
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<div className="w-12 flex justify-end">
|
||||
<Status status={StatusEnum.Live}>Live</Status>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-16 text-right font-mono text-sm- font-medium">
|
||||
{deployment.commitSHA.substring(0, 7)}
|
||||
</div>
|
||||
|
||||
<div className="w-[80px] text-right font-mono text-sm- font-medium">
|
||||
<AppDeploymentDuration
|
||||
startedAt={deployment.deploymentStartedAt}
|
||||
endedAt={deployment.deploymentEndedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatusCircle
|
||||
status={deployment.deploymentStatus as DeploymentStatus}
|
||||
/>
|
||||
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DeploymentListItem';
|
||||
export { default } from './DeploymentListItem';
|
||||
@@ -37,7 +37,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
parseInt(router.query.page as string, 10) - 1 || 0,
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<SortingRule<StoredFile>[]>();
|
||||
const limit = 25;
|
||||
const limit = 10;
|
||||
const emptyStateMessage = searchString
|
||||
? 'No search results found.'
|
||||
: 'No files are uploaded yet.';
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import {
|
||||
refetchGetOneUserQuery,
|
||||
useInsertWorkspaceMutation,
|
||||
useUpdateWorkspaceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface EditWorkspaceNameFormProps {
|
||||
/**
|
||||
* The current workspace name if this is an edit operation.
|
||||
*/
|
||||
currentWorkspaceName?: string;
|
||||
/**
|
||||
* The current workspace name id if this is an edit operation.
|
||||
*/
|
||||
currentWorkspaceId?: string;
|
||||
/**
|
||||
* Determines whether the form is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
* @default 'Create'
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => void;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
}
|
||||
|
||||
export interface EditWorkspaceNameFormValues {
|
||||
/**
|
||||
* New workspace name.
|
||||
*/
|
||||
newWorkspaceName: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
newWorkspaceName: Yup.string()
|
||||
.required('Workspace name is required.')
|
||||
.min(4, 'The new Workspace name must be at least 4 characters.')
|
||||
.max(32, "The new Workspace name can't be longer than 32 characters.")
|
||||
.test(
|
||||
'canBeSlugified',
|
||||
`This field should be at least 4 characters and can't be longer than 32 characters.`,
|
||||
(value) => {
|
||||
const slug = slugifyString(value);
|
||||
|
||||
if (slug.length < 4 || slug.length > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export default function EditWorkspaceName({
|
||||
disabled,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
currentWorkspaceName,
|
||||
currentWorkspaceId,
|
||||
submitButtonText = 'Create',
|
||||
}: EditWorkspaceNameFormProps) {
|
||||
const currentUser = useUserData();
|
||||
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
|
||||
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
|
||||
refetchQueries: [
|
||||
refetchGetOneUserQuery({
|
||||
userId: currentUser.id,
|
||||
}),
|
||||
],
|
||||
awaitRefetchQueries: true,
|
||||
ignoreResults: true,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<EditWorkspaceNameFormValues>({
|
||||
defaultValues: {
|
||||
newWorkspaceName: currentWorkspaceName || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { dirtyFields, isSubmitting, errors },
|
||||
} = form;
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
async function handleSubmit({
|
||||
newWorkspaceName,
|
||||
}: EditWorkspaceNameFormValues) {
|
||||
const slug = slugifyString(newWorkspaceName);
|
||||
|
||||
try {
|
||||
if (currentWorkspaceId) {
|
||||
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `mutating: true`.
|
||||
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
|
||||
// i.e. redirecting to 404 if there's no workspace/project with that slug.
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, updating: true },
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateWorkspaceName({
|
||||
variables: {
|
||||
id: currentWorkspaceId,
|
||||
workspace: {
|
||||
name: newWorkspaceName,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Updating workspace name...',
|
||||
success: 'Workspace name has been updated successfully.',
|
||||
error: 'An error occurred while updating the workspace name.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
} else {
|
||||
await toast.promise(
|
||||
insertWorkspace({
|
||||
variables: {
|
||||
workspace: {
|
||||
name: newWorkspaceName,
|
||||
companyName: newWorkspaceName,
|
||||
email: currentUser.email,
|
||||
slug,
|
||||
workspaceMembers: {
|
||||
data: [
|
||||
{
|
||||
userId: currentUser.id,
|
||||
type: 'owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Creating new workspace...',
|
||||
success: 'The new workspace has been created successfully.',
|
||||
error: 'An error occurred while creating the new workspace.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message?.includes('duplicate key value')) {
|
||||
form.setError(
|
||||
'newWorkspaceName',
|
||||
{
|
||||
type: 'manual',
|
||||
message: 'This workspace name is already taken.',
|
||||
},
|
||||
{
|
||||
shouldFocus: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
|
||||
await router.push(slug);
|
||||
onSubmit?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
|
||||
>
|
||||
<div className="flex-auto px-6 overflow-y-auto">
|
||||
<Input
|
||||
{...register('newWorkspaceName')}
|
||||
error={Boolean(errors.newWorkspaceName?.message)}
|
||||
label="Name"
|
||||
helperText={errors.newWorkspaceName?.message}
|
||||
autoFocus={!disabled}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
placeholder='e.g. "My Workspace"'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-shrink-0 grid-flow-row gap-2 px-6 pt-4">
|
||||
{!disabled && (
|
||||
<Button
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting || Boolean(errors.newWorkspaceName?.message)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{currentWorkspaceName ? 'Save' : submitButtonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
tabIndex={isDirty ? -1 : 0}
|
||||
autoFocus={disabled}
|
||||
>
|
||||
{disabled ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './EditWorkspaceNameForm';
|
||||
export { default } from './EditWorkspaceNameForm';
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { useInsertFeedbackOneMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import * as React from 'react';
|
||||
|
||||
export function SendFeedback({ setFeedbackSent, feedback, setFeedback }: any) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [insertFeedback, { loading }] = useInsertFeedbackOneMutation();
|
||||
const user = nhost.auth.getUser();
|
||||
const user = useUserData();
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const feedbackWithProjectInfo = [
|
||||
currentApplication && `Project ID: ${currentApplication.id}`,
|
||||
typeof window !== 'undefined' && `URL: ${window.location.href}`,
|
||||
feedback,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
try {
|
||||
await insertFeedback({
|
||||
variables: {
|
||||
feedback: {
|
||||
feedback,
|
||||
feedback: feedbackWithProjectInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,11 +4,8 @@ import { InviteAnnounce } from '@/components/home/InviteAnnounce';
|
||||
import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
||||
import BaseLayout from '@/components/layout/BaseLayout';
|
||||
import Container from '@/components/layout/Container';
|
||||
import AddWorkspace from '@/components/workspace/AddWorkspace';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import useIsHealthy from '@/hooks/common/useIsHealthy';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { Modal } from '@/ui';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -39,7 +36,6 @@ export default function AuthenticatedLayout({
|
||||
}: AuthenticatedLayoutProps) {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { newWorkspace, closeSection } = useUI();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const isHealthy = useIsHealthy();
|
||||
|
||||
@@ -85,7 +81,7 @@ export default function AuthenticatedLayout({
|
||||
<BaseLayout {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto" />
|
||||
|
||||
<Container className="my-12 grid max-w-md grid-flow-row justify-center gap-2 text-center">
|
||||
<Container className="grid justify-center max-w-md grid-flow-row gap-2 my-12 text-center">
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
src="/terminal-text.svg"
|
||||
@@ -123,13 +119,7 @@ export default function AuthenticatedLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout className="flex h-full flex-col" {...props}>
|
||||
<Modal
|
||||
showModal={newWorkspace}
|
||||
close={closeSection}
|
||||
Component={AddWorkspace}
|
||||
/>
|
||||
|
||||
<BaseLayout className="flex flex-col h-full" {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto" />
|
||||
|
||||
<InviteAnnounce />
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { AppDeploymentDuration } from '@/components/applications/AppDeployments';
|
||||
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
|
||||
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import NavLink from '@/components/common/NavLink';
|
||||
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import Status, { StatusEnum } from '@/ui/Status';
|
||||
import type { DeploymentStatus } from '@/ui/StatusCircle';
|
||||
import { StatusCircle } from '@/ui/StatusCircle';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import RocketIcon from '@/ui/v2/icons/RocketIcon';
|
||||
import type { ListItemRootProps } from '@/ui/v2/ListItem';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import List from '@/ui/v2/List';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { getLastLiveDeployment } from '@/utils/helpers';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import { useGetDeploymentsSubSubscription } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetDeploymentsSubSubscription,
|
||||
useScheduledOrPendingDeploymentsSubSubscription,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function OverviewDeploymentsTopBar() {
|
||||
@@ -54,92 +50,6 @@ function OverviewDeploymentsTopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewDeployProps extends ListItemRootProps {
|
||||
/**
|
||||
* Deployment metadata to display.
|
||||
*/
|
||||
deployment: DeploymentRowFragment;
|
||||
/**
|
||||
* Determines to show a status badge showing the live status of a deployment reflecting the latest state of the application.
|
||||
*/
|
||||
isDeploymentLive: boolean;
|
||||
}
|
||||
|
||||
function OverviewDeployment({
|
||||
deployment,
|
||||
isDeploymentLive,
|
||||
className,
|
||||
}: OverviewDeployProps) {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
|
||||
const relativeDateOfDeployment = formatDistanceToNowStrict(
|
||||
parseISO(deployment.deploymentStartedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { commitMessage } = deployment;
|
||||
|
||||
return (
|
||||
<ListItem.Root className={twMerge('grid grid-flow-row', className)}>
|
||||
<ListItem.Button
|
||||
className="grid grid-flow-col items-center justify-between gap-2 px-2 py-2"
|
||||
component={NavLink}
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center">
|
||||
<div>
|
||||
<Avatar
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-flow-row truncate text-sm+ font-medium">
|
||||
<Text className="inline cursor-pointer truncate font-medium leading-snug text-greyscaleDark">
|
||||
{commitMessage?.trim() || (
|
||||
<span className="truncate pr-1 font-normal italic">
|
||||
No commit message
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
<Text className="text-sm font-normal leading-[1.375rem] text-greyscaleGrey">
|
||||
{relativeDateOfDeployment}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-flow-col items-center self-center">
|
||||
{isDeploymentLive && (
|
||||
<div className="flex self-center align-middle">
|
||||
<Status status={StatusEnum.Live}>Live</Status>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-20 self-center text-right align-middle font-mono text-sm- font-medium">
|
||||
{deployment.commitSHA.substring(0, 7)}
|
||||
</div>
|
||||
<div className="w-20 self-center text-right align-middle font-mono text-sm-">
|
||||
<AppDeploymentDuration
|
||||
startedAt={deployment.deploymentStartedAt}
|
||||
endedAt={deployment.deploymentEndedAt}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-3 self-center">
|
||||
<StatusCircle
|
||||
status={deployment.deploymentStatus as DeploymentStatus}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
|
||||
</div>
|
||||
</div>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewDeploymentsProps {
|
||||
projectId: string;
|
||||
githubRepository: { fullName: string };
|
||||
@@ -159,9 +69,18 @@ function OverviewDeployments({
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
const {
|
||||
data: scheduledOrPendingDeploymentsData,
|
||||
loading: scheduledOrPendingDeploymentsLoading,
|
||||
} = useScheduledOrPendingDeploymentsSubSubscription({
|
||||
variables: {
|
||||
appId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading || scheduledOrPendingDeploymentsLoading) {
|
||||
return (
|
||||
<div style={{ height: '240px' }}>
|
||||
<div className="h-60">
|
||||
<ActivityIndicator label="Loading deployments..." />
|
||||
</div>
|
||||
);
|
||||
@@ -218,22 +137,22 @@ function OverviewDeployments({
|
||||
);
|
||||
}
|
||||
|
||||
const getLastLiveDeploymentId = getLastLiveDeployment(deployments);
|
||||
const liveDeploymentId = getLastLiveDeployment(deployments);
|
||||
const { deployments: scheduledOrPendingDeployments } =
|
||||
scheduledOrPendingDeploymentsData;
|
||||
|
||||
return (
|
||||
<div className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
|
||||
{deployments.map((deployment) => {
|
||||
const isDeploymentLive = deployment.id === getLastLiveDeploymentId;
|
||||
|
||||
return (
|
||||
<OverviewDeployment
|
||||
key={deployment.id}
|
||||
deployment={deployment}
|
||||
isDeploymentLive={isDeploymentLive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<List className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
|
||||
{deployments.map((deployment, index) => (
|
||||
<DeploymentListItem
|
||||
key={deployment.id}
|
||||
deployment={deployment}
|
||||
isLive={deployment.id === liveDeploymentId}
|
||||
showRedeploy={index === 0}
|
||||
disableRedeploy={scheduledOrPendingDeployments.length > 0}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AllowedEmailSettingsFormValues {
|
||||
/**
|
||||
* Determines whether or not the allowed email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of email that are allowed to be used for project's users authentication.
|
||||
*/
|
||||
@@ -25,7 +29,6 @@ export interface AllowedEmailSettingsFormValues {
|
||||
export default function AllowedEmailDomainsSettings() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
@@ -36,12 +39,30 @@ export default function AllowedEmailDomainsSettings() {
|
||||
const form = useForm<AllowedEmailSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlAllowedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
|
||||
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
|
||||
authAccessControlAllowedEmailDomains:
|
||||
data?.app?.authAccessControlAllowedEmailDomains,
|
||||
},
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlAllowedEmails &&
|
||||
!data.app?.authAccessControlAllowedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -56,8 +77,6 @@ export default function AllowedEmailDomainsSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: AllowedEmailSettingsFormValues,
|
||||
) => {
|
||||
@@ -65,7 +84,12 @@ export default function AllowedEmailDomainsSettings() {
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
authAccessControlAllowedEmails: values.enabled
|
||||
? values.authAccessControlAllowedEmails
|
||||
: '',
|
||||
authAccessControlAllowedEmailDomains: values.enabled
|
||||
? values.authAccessControlAllowedEmailDomains
|
||||
: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -89,13 +113,17 @@ export default function AllowedEmailDomainsSettings() {
|
||||
<SettingsContainer
|
||||
title="Allowed Emails and Domains"
|
||||
description="Allow specific email addresses and domains to sign up."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={setEnabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function AllowedRedirectURLsSettings() {
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
|
||||
className="grid grid-flow-row px-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface BlockedEmailFormValues {
|
||||
/**
|
||||
* Determines whether or not the blocked email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of emails that are blocked from registering to the user's project.
|
||||
*/
|
||||
@@ -24,7 +28,6 @@ export interface BlockedEmailFormValues {
|
||||
export default function BlockedEmailSettings() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
@@ -35,12 +38,30 @@ export default function BlockedEmailSettings() {
|
||||
const form = useForm<BlockedEmailFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlBlockedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
|
||||
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
|
||||
authAccessControlBlockedEmailDomains:
|
||||
data?.app?.authAccessControlBlockedEmailDomains,
|
||||
},
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlBlockedEmails &&
|
||||
!data.app?.authAccessControlBlockedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -55,8 +76,6 @@ export default function BlockedEmailSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: BlockedEmailFormValues,
|
||||
) => {
|
||||
@@ -64,7 +83,12 @@ export default function BlockedEmailSettings() {
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
authAccessControlBlockedEmails: values.enabled
|
||||
? values.authAccessControlBlockedEmails
|
||||
: '',
|
||||
authAccessControlBlockedEmailDomains: values.enabled
|
||||
? values.authAccessControlBlockedEmailDomains
|
||||
: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -88,13 +112,17 @@ export default function BlockedEmailSettings() {
|
||||
<SettingsContainer
|
||||
title="Blocked Emails and Domains"
|
||||
description="Block specific email addresses and domains to sign up."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={setEnabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function ClientURLSettings() {
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#client-url"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function DisableNewUsersSettings() {
|
||||
<SettingsContainer
|
||||
title="Disable New Users"
|
||||
description="If set, newly registered users are disabled and won’t be able to sign in."
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#disable-new-users"
|
||||
switchId="authDisableNewUsers"
|
||||
showSwitch
|
||||
enabled={authDisableNewUsers}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function GravatarSettings() {
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#gravatar"
|
||||
switchId="authGravatarEnabled"
|
||||
showSwitch
|
||||
enabled={authGravatarEnabled}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function MFASettings() {
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication"
|
||||
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
|
||||
switchId="authMfaEnabled"
|
||||
enabled={authMfaEnabled}
|
||||
showSwitch
|
||||
|
||||
@@ -58,10 +58,10 @@ export default function BaseRoleForm({
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-3 px-6 pb-6">
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the name for the role below.
|
||||
Enter the name for the allowed role below.
|
||||
</Text>
|
||||
|
||||
{submitButtonText !== 'Create' && (
|
||||
{submitButtonText !== 'Add' && (
|
||||
<Alert severity="warning" className="text-left">
|
||||
<span className="text-left">
|
||||
<strong>Note:</strong> Changing the name of the role will lose the
|
||||
|
||||
@@ -85,11 +85,7 @@ export default function CreateRoleForm({
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BaseRoleForm
|
||||
submitButtonText="Create"
|
||||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
/>
|
||||
<BaseRoleForm submitButtonText="Add" onSubmit={handleSubmit} {...props} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@ import Chip from '@/ui/v2/Chip';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -98,9 +98,9 @@ export default function RoleSettings() {
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Deleting role...',
|
||||
success: 'Role has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete the role.',
|
||||
loading: 'Deleting allowed role...',
|
||||
success: 'Allowed Role has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete the allowed role.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
@@ -108,7 +108,7 @@ export default function RoleSettings() {
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('CREATE_ROLE', {
|
||||
title: 'Create Role',
|
||||
title: 'Create Allowed Role',
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
@@ -118,7 +118,7 @@ export default function RoleSettings() {
|
||||
|
||||
function handleOpenEditor(originalRole: Role) {
|
||||
openDialog('EDIT_ROLE', {
|
||||
title: 'Edit Role',
|
||||
title: 'Edit Allowed Role',
|
||||
payload: { originalRole },
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
@@ -129,12 +129,11 @@ export default function RoleSettings() {
|
||||
|
||||
function handleConfirmDelete(originalRole: Role) {
|
||||
openAlertDialog({
|
||||
title: 'Delete Role',
|
||||
title: 'Delete Allowed Role',
|
||||
payload: (
|
||||
<Text>
|
||||
Are you sure you want to delete the "
|
||||
<strong>{originalRole.name}</strong>" role? This cannot be
|
||||
undone.
|
||||
Are you sure you want to delete the allowed role "
|
||||
<strong>{originalRole.name}</strong>"?.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
@@ -145,13 +144,15 @@ export default function RoleSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||
const availableAllowedRoles = getUserRoles(
|
||||
data?.app?.authUserDefaultAllowedRoles,
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Roles"
|
||||
description="Roles are used to control access to your application."
|
||||
docsLink="https://docs.nhost.io/authentication/users#roles"
|
||||
title="Allowed Roles"
|
||||
description="Allowed roles are roles users get automatically when they sign up."
|
||||
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
|
||||
rootClassName="gap-0"
|
||||
className="px-0 my-2"
|
||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||
@@ -162,7 +163,7 @@ export default function RoleSettings() {
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availableRoles.map((role, index) => (
|
||||
{availableAllowedRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
@@ -249,7 +250,9 @@ export default function RoleSettings() {
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
|
||||
index === availableAllowedRoles.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
@@ -262,7 +265,7 @@ export default function RoleSettings() {
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
>
|
||||
Create Role
|
||||
Create Allowed Role
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function MagicLinkSettings() {
|
||||
<Form onSubmit={handleMagicLinkSettingsUpdate}>
|
||||
<SettingsContainer
|
||||
title="Magic Link"
|
||||
description="Allow users to sign in with a magic link."
|
||||
description="Allow users to sign in with a Magic Link."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function SMSSettings() {
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSMSSettingsChange}>
|
||||
<SettingsContainer
|
||||
title="SMS"
|
||||
title="Phone Number (SMS)"
|
||||
description="Allow users to sign in with Phone Number (SMS)."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type DeploymentStatus =
|
||||
| 'DEPLOYING'
|
||||
| 'DEPLOYED'
|
||||
| 'FAILED'
|
||||
| 'PENDING'
|
||||
| 'SCHEDULED'
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
@@ -12,32 +14,30 @@ type StatusCircleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StatusCircle(props: StatusCircleProps) {
|
||||
const { status, className } = props;
|
||||
|
||||
export function StatusCircle({ status, className }: StatusCircleProps) {
|
||||
const baseClasses = 'w-1.5 h-1.5 rounded-full';
|
||||
|
||||
if (!status) {
|
||||
const classes = clsx(baseClasses, 'bg-gray-300', className);
|
||||
return <div className={classes} />;
|
||||
}
|
||||
|
||||
if (status === 'DEPLOYING') {
|
||||
const classes = clsx(baseClasses, 'bg-yellow-300', className);
|
||||
return <div className={classes} />;
|
||||
if (status === 'DEPLOYING' || status === 'PENDING') {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
baseClasses,
|
||||
'bg-yellow-300 animate-pulse',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'DEPLOYED') {
|
||||
const classes = clsx(baseClasses, 'bg-green-300', className);
|
||||
return <div className={classes} />;
|
||||
return <div className={twMerge(baseClasses, 'bg-green-300', className)} />;
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
const classes = clsx(baseClasses, 'bg-red', className);
|
||||
return <div className={classes} />;
|
||||
return <div className={twMerge(baseClasses, 'bg-red', className)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
return <div className={twMerge(baseClasses, 'bg-gray-300', className)} />;
|
||||
}
|
||||
|
||||
export default StatusCircle;
|
||||
|
||||
17
dashboard/src/components/ui/v2/ListItem/ListItemAvatar.tsx
Normal file
17
dashboard/src/components/ui/v2/ListItem/ListItemAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { ListItemAvatarProps as MaterialListItemAvatarProps } from '@mui/material/ListItemAvatar';
|
||||
import MaterialListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
|
||||
export interface ListItemAvatarProps extends MaterialListItemAvatarProps {}
|
||||
|
||||
const StyledListItemAvatar = styled(MaterialListItemAvatar)({
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
function ListItemAvatar({ children, ...props }: ListItemAvatarProps) {
|
||||
return <StyledListItemAvatar {...props}>{children}</StyledListItemAvatar>;
|
||||
}
|
||||
|
||||
ListItemAvatar.displayName = 'NhostListItemAvatar';
|
||||
|
||||
export default ListItemAvatar;
|
||||
@@ -1,8 +1,11 @@
|
||||
import ListItemAvatar from './ListItemAvatar';
|
||||
import ListItemButton from './ListItemButton';
|
||||
import ListItemIcon from './ListItemIcon';
|
||||
import ListItemRoot from './ListItemRoot';
|
||||
import ListItemText from './ListItemText';
|
||||
|
||||
export * from './ListItemAvatar';
|
||||
export { default as ListItemAvatar } from './ListItemAvatar';
|
||||
export * from './ListItemButton';
|
||||
export { default as ListItemButton } from './ListItemButton';
|
||||
export * from './ListItemIcon';
|
||||
@@ -13,6 +16,7 @@ export * from './ListItemText';
|
||||
export { default as ListItemText } from './ListItemText';
|
||||
|
||||
export const ListItem = {
|
||||
Avatar: ListItemAvatar,
|
||||
Root: ListItemRoot,
|
||||
Button: ListItemButton,
|
||||
Icon: ListItemIcon,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IconProps } from '@/ui/v2/icons';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
|
||||
function ArrowCounterclockwiseIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="A counterclockwise arrow"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.99 6.232h-3v-3"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.11 11.89a5.5 5.5 0 1 0 0-7.78L1.99 6.233"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ArrowCounterclockwiseIcon.displayName = 'NhostArrowCounterclockwiseIcon';
|
||||
|
||||
export default ArrowCounterclockwiseIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ArrowCounterclockwiseIcon';
|
||||
@@ -67,8 +67,8 @@ export default function CreateUserForm({
|
||||
} = form;
|
||||
|
||||
const baseAuthUrl = generateAppServiceUrl(
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
currentApplication?.subdomain,
|
||||
currentApplication?.region?.awsName,
|
||||
'auth',
|
||||
);
|
||||
|
||||
|
||||
@@ -8,18 +8,18 @@ import Button from '@/ui/v2/Button';
|
||||
import Chip from '@/ui/v2/Chip';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import CopyIcon from '@/ui/v2/icons/CopyIcon';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import InputLabel from '@/ui/v2/InputLabel';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import CopyIcon from '@/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Avatar } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
@@ -137,7 +137,7 @@ export default function EditUserForm({
|
||||
}
|
||||
|
||||
const { data: dataRoles } = useGetRolesQuery({
|
||||
variables: { id: currentApplication.id },
|
||||
variables: { id: currentApplication?.id },
|
||||
});
|
||||
|
||||
const allAvailableProjectRoles = getUserRoles(
|
||||
@@ -206,11 +206,7 @@ export default function EditUserForm({
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
autoFocus={false}
|
||||
asChild
|
||||
className="gap-2"
|
||||
>
|
||||
<Dropdown.Trigger autoFocus={false} asChild className="gap-2">
|
||||
<Button variant="outlined" color="secondary">
|
||||
Actions
|
||||
</Button>
|
||||
|
||||
@@ -7,12 +7,14 @@ import Chip from '@/ui/v2/Chip';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import DotsHorizontalIcon from '@/ui/v2/icons/DotsHorizontalIcon';
|
||||
import TrashIcon from '@/ui/v2/icons/TrashIcon';
|
||||
import UserIcon from '@/ui/v2/icons/UserIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useDeleteRemoteAppUserRolesMutation,
|
||||
@@ -21,9 +23,6 @@ import {
|
||||
useRemoteAppDeleteUserMutation,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { ApolloQueryResult } from '@apollo/client';
|
||||
import { Avatar } from '@mui/material';
|
||||
import { formatDistance } from 'date-fns';
|
||||
@@ -77,7 +76,7 @@ export default function UsersBody({
|
||||
* in the drawer form.
|
||||
*/
|
||||
const { data: dataRoles } = useGetRolesQuery({
|
||||
variables: { id: currentApplication.id },
|
||||
variables: { id: currentApplication?.id },
|
||||
});
|
||||
|
||||
const allAvailableProjectRoles = useMemo(
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useInsertWorkspaceMutation } from '@/generated/graphql';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { getErrorMessage, inputErrorMessages } from '@/utils/getErrorMessage';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import router from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import slugify from 'slugify';
|
||||
|
||||
function AddNewWorkspaceForm({
|
||||
closeSection: externalCloseSection,
|
||||
}: {
|
||||
closeSection: VoidFunction;
|
||||
}) {
|
||||
const [workspace, setWorkspace] = useState('');
|
||||
const { closeSection } = useUI();
|
||||
const [workspaceError, setWorkspaceError] = useState<string>('');
|
||||
const [loadingAddWorkspace, setLoadingAddWorkspace] = useState(false);
|
||||
|
||||
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
|
||||
|
||||
const slug = slugify(workspace, { lower: true, strict: true });
|
||||
const user = nhost.auth.getUser();
|
||||
if (!user) {
|
||||
return <div>No user..</div>;
|
||||
}
|
||||
const userId = user.id;
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setWorkspaceError('');
|
||||
setLoadingAddWorkspace(true);
|
||||
|
||||
if (
|
||||
!inputErrorMessages(
|
||||
workspace,
|
||||
setWorkspace,
|
||||
setWorkspaceError,
|
||||
'Workspace',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (slug.length < 4 || slug.length > 32) {
|
||||
setWorkspaceError('Slug should be within 4 and 32 characters.');
|
||||
setLoadingAddWorkspace(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = nhost.auth.getUser();
|
||||
|
||||
if (!currentUser) {
|
||||
triggerToast('User is not signed in');
|
||||
setLoadingAddWorkspace(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await insertWorkspace({
|
||||
variables: {
|
||||
workspace: {
|
||||
name: workspace,
|
||||
companyName: workspace,
|
||||
email: user.email,
|
||||
slug,
|
||||
workspaceMembers: {
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
type: 'owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.refetchQueries({ include: ['getOneUser'] });
|
||||
router.push(`/${slug}`);
|
||||
setLoadingAddWorkspace(false);
|
||||
closeSection();
|
||||
} catch (error: any) {
|
||||
setWorkspaceError(getErrorMessage(error, 'workspace'));
|
||||
setLoadingAddWorkspace(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Your new workspace"
|
||||
name="workspace"
|
||||
id="workspace"
|
||||
label="Workspace"
|
||||
fullWidth
|
||||
autoFocus
|
||||
helperText={`https://app.nhost.io/${slugifyString(workspace)}`}
|
||||
onChange={(event) => {
|
||||
setWorkspace(event.target.value);
|
||||
setWorkspaceError('');
|
||||
}}
|
||||
/>
|
||||
|
||||
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
|
||||
|
||||
<div className="grid grid-flow-col justify-between gap-2">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
externalCloseSection();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!!workspaceError}
|
||||
loading={loadingAddWorkspace}
|
||||
>
|
||||
Create Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddWorkspace() {
|
||||
const { closeSection } = useUI();
|
||||
|
||||
const user = nhost.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return <div>No user..</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-modal grid-flow-row gap-2 px-6 py-6 text-left">
|
||||
<div className="grid w-full grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
New Workspace
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Invite team members to workspaces to work collaboratively.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AddNewWorkspaceForm closeSection={closeSection} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function SidebarWorkspaces() {
|
||||
const user = nhost.auth.getUser();
|
||||
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
|
||||
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery({
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(1000);
|
||||
@@ -28,7 +30,7 @@ export default function SidebarWorkspaces() {
|
||||
<div className="mt-3 mb-4 space-y-2">
|
||||
<div className="flex flex-row">
|
||||
<svg
|
||||
className="ml-1 h-4 w-4 animate-spin self-center text-dark"
|
||||
className="self-center w-4 h-4 ml-1 animate-spin text-dark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -47,7 +49,7 @@ export default function SidebarWorkspaces() {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<Text size="tiny" className="ml-2 self-center" color="greyscaleGrey">
|
||||
<Text size="tiny" className="self-center ml-2" color="greyscaleGrey">
|
||||
Creating first workspace...
|
||||
</Text>
|
||||
</div>
|
||||
@@ -66,12 +68,12 @@ export default function SidebarWorkspaces() {
|
||||
>
|
||||
{name === 'Default Workspace' && creatorUserId === user.id ? (
|
||||
<Avatar
|
||||
className="h-8 w-8 self-center rounded-full"
|
||||
className="self-center w-8 h-8 rounded-full"
|
||||
name={user?.displayName}
|
||||
avatarUrl={user?.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-block h-8 w-8 overflow-hidden rounded-lg">
|
||||
<div className="inline-block w-8 h-8 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChangeWorkspaceName from '@/components/applications/ChangeWorkspaceName';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useGetWorkspace } from '@/hooks/use-GetWorkspace';
|
||||
@@ -13,7 +13,6 @@ import { copy } from '@/utils/copy';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function WorkspaceHeader() {
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
|
||||
@@ -21,14 +20,14 @@ export default function WorkspaceHeader() {
|
||||
query: { workspaceSlug },
|
||||
} = useRouter();
|
||||
|
||||
const [changeWorkspaceNameModal, setChangeWorkspaceNameModal] =
|
||||
useState(false);
|
||||
const {
|
||||
openDeleteWorkspaceModal,
|
||||
closeDeleteWorkspaceModal,
|
||||
deleteWorkspaceModal,
|
||||
} = useUI();
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { data } = useGetWorkspace(workspaceSlug);
|
||||
|
||||
const workspace = data?.workspaces[0];
|
||||
@@ -45,11 +44,6 @@ export default function WorkspaceHeader() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col">
|
||||
<Modal
|
||||
showModal={changeWorkspaceNameModal}
|
||||
close={() => setChangeWorkspaceNameModal(!changeWorkspaceNameModal)}
|
||||
Component={ChangeWorkspaceName}
|
||||
/>
|
||||
<Modal
|
||||
showModal={deleteWorkspaceModal}
|
||||
close={closeDeleteWorkspaceModal}
|
||||
@@ -112,9 +106,23 @@ export default function WorkspaceHeader() {
|
||||
>
|
||||
<Dropdown.Item
|
||||
className="py-2"
|
||||
onClick={() =>
|
||||
setChangeWorkspaceNameModal(!changeWorkspaceNameModal)
|
||||
}
|
||||
onClick={() => {
|
||||
openDialog('EDIT_WORKSPACE_NAME', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Change Workspace Name</span>
|
||||
<Text variant="subtitle1" component="span">
|
||||
Changing the workspace name will also affect the URL
|
||||
of the workspace.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
currentWorkspaceName: currentWorkspace.name,
|
||||
currentWorkspaceId: currentWorkspace.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Change workspace name
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { SidebarTitle } from '@/components/home/SidebarTitle';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
|
||||
import SidebarWorkspaces from './SidebarWorkspaces';
|
||||
|
||||
export function WorkspaceSection() {
|
||||
const { openSection } = useUI();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -15,7 +16,19 @@ export function WorkspaceSection() {
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={openSection}
|
||||
onClick={() => {
|
||||
openDialog('EDIT_WORKSPACE_NAME', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>New Workspace</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
Invite team members to workspaces to work collaboratively.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}}
|
||||
startIcon={<PlusCircleIcon />}
|
||||
>
|
||||
New Workspace
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
const terminalTheme = {
|
||||
hljs: {
|
||||
display: 'block',
|
||||
background: '#F4F7F9',
|
||||
color: '#21324B',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#9C73DF',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#9C73DF',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#9C73DF',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#9C73DF',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-strong': {
|
||||
color: '#9C73DF',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#9C73DF',
|
||||
},
|
||||
'hljs-code': {
|
||||
color: '#66d9ef',
|
||||
},
|
||||
'hljs-class .hljs-title': {
|
||||
color: 'red',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#bf79db',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#bf79db',
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#bf79db',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#bf79db',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#B7A590',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#B7A590',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#B7A590',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#B7A590',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#B7A590',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#3ECF8E',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-selector-attr': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-selector-pseudo': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#3ECF8E',
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#21324B',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#75715e',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#75715e',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#75715e',
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
};
|
||||
|
||||
export default terminalTheme;
|
||||
@@ -20,6 +20,34 @@ query getDeployments($id: uuid!, $limit: Int!, $offset: Int!) {
|
||||
}
|
||||
}
|
||||
|
||||
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
|
||||
deployments(
|
||||
where: {
|
||||
deploymentStatus: { _in: ["PENDING", "SCHEDULED"] }
|
||||
appId: { _eq: $appId }
|
||||
}
|
||||
) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
|
||||
subscription LatestLiveDeploymentSub($appId: uuid!) {
|
||||
deployments(
|
||||
where: { deploymentStatus: { _eq: "DEPLOYED" }, appId: { _eq: $appId } }
|
||||
order_by: { deploymentEndedAt: desc }
|
||||
limit: 1
|
||||
offset: 0
|
||||
) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
|
||||
mutation InsertDeployment($object: deployments_insert_input!) {
|
||||
insertDeployment(object: $object) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
|
||||
subscription getDeploymentsSub($id: uuid!, $limit: Int!, $offset: Int!) {
|
||||
deployments(
|
||||
where: { appId: { _eq: $id } }
|
||||
|
||||
@@ -6,7 +6,6 @@ import FileTextIcon from '@/ui/v2/icons/FileTextIcon';
|
||||
import GraphQLIcon from '@/ui/v2/icons/GraphQLIcon';
|
||||
import HasuraIcon from '@/ui/v2/icons/HasuraIcon';
|
||||
import HomeIcon from '@/ui/v2/icons/HomeIcon';
|
||||
import LambdaIcon from '@/ui/v2/icons/LambdaIcon';
|
||||
import RocketIcon from '@/ui/v2/icons/RocketIcon';
|
||||
import StorageIcon from '@/ui/v2/icons/StorageIcon';
|
||||
import UserIcon from '@/ui/v2/icons/UserIcon';
|
||||
@@ -54,13 +53,6 @@ export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/functions',
|
||||
exact: false,
|
||||
label: 'Functions',
|
||||
icon: <LambdaIcon />,
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
relativePath: '/deployments',
|
||||
exact: false,
|
||||
|
||||
@@ -237,7 +237,10 @@ test('should drop existing relationships and prepare a new one-to-many relations
|
||||
"cascade": false,
|
||||
"relationship": "books",
|
||||
"source": "default",
|
||||
"table": "authors",
|
||||
"table": {
|
||||
"name": "authors",
|
||||
"schema": "public",
|
||||
},
|
||||
},
|
||||
"type": "pg_drop_relationship",
|
||||
}
|
||||
|
||||
@@ -152,7 +152,12 @@ export default async function prepareTrackForeignKeyRelationsMetadata({
|
||||
type: 'pg_drop_relationship',
|
||||
args: {
|
||||
source: dataSource,
|
||||
table: foreignKeyRelation.referencedTable,
|
||||
table: foreignKeyRelation.referencedSchema
|
||||
? {
|
||||
name: foreignKeyRelation.referencedTable,
|
||||
schema: foreignKeyRelation.referencedSchema,
|
||||
}
|
||||
: foreignKeyRelation.referencedTable,
|
||||
relationship: oneToManyRelationshipName,
|
||||
cascade: false,
|
||||
},
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function prepareUpdateTableQuery({
|
||||
);
|
||||
|
||||
return [
|
||||
...args,
|
||||
...updatedArgs,
|
||||
...prepareUpdateForeignKeyConstraintQuery({
|
||||
...baseVariables,
|
||||
originalForeignKeyRelation,
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function useFiles({
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
'storage',
|
||||
)}/${file.id}`;
|
||||
)}/files/${file.id}`;
|
||||
|
||||
const fetchParams = new URLSearchParams();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function useNotFoundRedirect() {
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const router = useRouter();
|
||||
const {
|
||||
query: { workspaceSlug, appSlug },
|
||||
query: { workspaceSlug, appSlug, updating },
|
||||
} = useRouter();
|
||||
|
||||
const notIn404Already = router.pathname !== '/404';
|
||||
@@ -25,6 +25,15 @@ export default function useNotFoundRedirect() {
|
||||
const inSettingsDatabasePage = router.pathname.includes('/settings/database');
|
||||
|
||||
useEffect(() => {
|
||||
// This code is checking if the URL has a query of the form `?updating=true`
|
||||
// If it does (`updating` is true) this useEffect will immediately exit without executing
|
||||
// any further statements (e.g. the page will show a loader until `updating` is false).
|
||||
// This is to prevent the user from being redirected to the 404 page while we are updating
|
||||
// either the workspace slug or application slug.
|
||||
if (updating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (noResolvedWorkspace && notIn404Already) {
|
||||
router.push('/404');
|
||||
}
|
||||
@@ -37,6 +46,7 @@ export default function useNotFoundRedirect() {
|
||||
router.push('/404');
|
||||
}
|
||||
}, [
|
||||
updating,
|
||||
currentApplication,
|
||||
currentWorkspace,
|
||||
noResolvedApplication,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppDeploymentDuration } from '@/components/applications/AppDeployments';
|
||||
import AppDeploymentDuration from '@/components/deployments/AppDeploymentDuration';
|
||||
import Container from '@/components/layout/Container';
|
||||
import ProjectLayout from '@/components/layout/ProjectLayout';
|
||||
import { useDeploymentSubSubscription } from '@/generated/graphql';
|
||||
@@ -8,6 +8,7 @@ import DelayedLoading from '@/ui/DelayedLoading';
|
||||
import type { DeploymentStatus } from '@/ui/StatusCircle';
|
||||
import { StatusCircle } from '@/ui/StatusCircle';
|
||||
import { Text } from '@/ui/Text';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import { format, formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
@@ -50,6 +51,12 @@ export default function DeploymentDetailsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const relativeDateOfDeployment = deployment.deploymentStartedAt
|
||||
? formatDistanceToNowStrict(parseISO(deployment.deploymentStartedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex justify-between">
|
||||
@@ -104,24 +111,20 @@ export default function DeploymentDetailsPage() {
|
||||
{deployment.commitMessage}
|
||||
</div>
|
||||
<div className="text-sm+ text-greyscaleGrey">
|
||||
{formatDistanceToNowStrict(
|
||||
parseISO(deployment.deploymentStartedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)}
|
||||
{relativeDateOfDeployment}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" flex items-center">
|
||||
<a
|
||||
className="self-center font-mono text-sm- font-medium text-greyscaleDark"
|
||||
<Link
|
||||
className="self-center font-mono text-sm- font-medium"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`https://github.com/${currentApplication.githubRepository?.fullName}/commit/${deployment.commitSHA}`}
|
||||
underline="hover"
|
||||
>
|
||||
{deployment.commitSHA.substring(0, 7)}
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="w-20 text-right">
|
||||
<AppDeploymentDuration
|
||||
@@ -133,6 +136,10 @@ export default function DeploymentDetailsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="rounded-lg bg-verydark p-4 text-sm- text-white">
|
||||
{deployment.deploymentLogs.length === 0 && (
|
||||
<span className="font-mono">No message.</span>
|
||||
)}
|
||||
|
||||
{deployment.deploymentLogs.map((log) => (
|
||||
<div key={log.id} className="flex font-mono">
|
||||
<div className=" mr-2 flex-shrink-0">
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import ConnectGithubModal from '@/components/applications/ConnectGithubModal';
|
||||
import { FunctionsNotDeployed } from '@/components/applications/functions/FunctionsNotDeployed';
|
||||
import { normalizeFunctionMetadata } from '@/components/applications/functions/normalizeFunctionMetadata';
|
||||
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
|
||||
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
||||
import Folder from '@/components/icons/Folder';
|
||||
import Container from '@/components/layout/Container';
|
||||
import ProjectLayout from '@/components/layout/ProjectLayout';
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Button } from '@/ui/Button';
|
||||
import DelayedLoading from '@/ui/DelayedLoading';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import Status, { StatusEnum } from '@/ui/Status';
|
||||
import { Text } from '@/ui/Text';
|
||||
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
|
||||
import { useGetAppFunctionsMetadataQuery } from '@/utils/__generated__/graphql';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function FunctionsNoRepo() {
|
||||
const [githubModal, setGithubModal] = useState(false);
|
||||
const [githubRepoModal, setGithubRepoModal] = useState(false);
|
||||
const { openGitHubModal } = useGitHubModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal showModal={githubModal} close={() => setGithubModal(!githubModal)}>
|
||||
<ConnectGithubModal close={() => setGithubModal(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
showModal={githubRepoModal}
|
||||
close={() => setGithubRepoModal(!githubRepoModal)}
|
||||
>
|
||||
<EditRepositorySettings
|
||||
openConnectGithubModal={() => setGithubModal(true)}
|
||||
close={() => setGithubRepoModal(false)}
|
||||
handleSelectAnotherRepository={openGitHubModal}
|
||||
/>
|
||||
</Modal>
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/assets/githubRepo.svg"
|
||||
width={72}
|
||||
height={72}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
</div>
|
||||
<Text className="mt-4 font-medium" size="large" color="dark">
|
||||
Function Logs
|
||||
</Text>
|
||||
<div className="flex">
|
||||
<div className="mx-auto flex flex-row self-center text-center">
|
||||
<Text size="normal" color="greyscaleDark" className="mt-1">
|
||||
To deploy serverless functions, you need to connect your project to
|
||||
version control.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex text-center">
|
||||
<Button
|
||||
transparent
|
||||
color="blue"
|
||||
className="mx-auto font-medium"
|
||||
onClick={() => setGithubModal(true)}
|
||||
>
|
||||
Connect your Project to GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FunctionsPage() {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const { workspaceContext } = useWorkspaceContext();
|
||||
|
||||
const { data, loading, error } = useGetAppFunctionsMetadataQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
});
|
||||
|
||||
const [normalizedFunctions, setNormalizedFunctions] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (data.app.metadataFunctions) {
|
||||
setNormalizedFunctions(
|
||||
normalizeFunctionMetadata(data.app.metadataFunctions),
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (!workspaceContext.repository) {
|
||||
return (
|
||||
<Container className="mt-12 max-w-3xl text-center antialiased">
|
||||
<FunctionsNoRepo />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container>
|
||||
<DelayedLoading delay={500} className="mt-12" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || normalizedFunctions === null) {
|
||||
return (
|
||||
<Container>
|
||||
<FunctionsNotDeployed />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Container>Error</Container>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mt-2">
|
||||
{normalizedFunctions?.map((folder) => (
|
||||
<div key={folder.folder}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-row pt-8 pb-2 align-middle',
|
||||
folder.nestedLevel < 2 && 'ml-6',
|
||||
folder.nestedLevel >= 2 && 'ml-12',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('flex w-full')}>
|
||||
{folder.nestedLevel > 0 && (
|
||||
<Folder className="self-center align-middle text-greyscaleGrey" />
|
||||
)}
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
folder.nestedLevel > 0 && 'ml-2',
|
||||
)}
|
||||
size="tiny"
|
||||
>
|
||||
{folder.folder}/
|
||||
</Text>
|
||||
</div>
|
||||
{folder.nestedLevel === 0 ? (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex w-52">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className="font-medium"
|
||||
size="tiny"
|
||||
>
|
||||
Created At
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-16 self-end">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className="font-medium"
|
||||
size="tiny"
|
||||
>
|
||||
Status
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-t py-1',
|
||||
folder.nestedLevel < 2 && 'ml-6',
|
||||
folder.nestedLevel >= 2 && 'ml-12',
|
||||
)}
|
||||
>
|
||||
{folder.funcs.map((func) => (
|
||||
<Link
|
||||
key={func.id}
|
||||
href={{
|
||||
pathname:
|
||||
'/[workspaceSlug]/[appSlug]/functions/[functionId]',
|
||||
query: {
|
||||
workspaceSlug: currentWorkspace.slug,
|
||||
appSlug: currentApplication.slug,
|
||||
functionId: func.functionName,
|
||||
},
|
||||
}}
|
||||
passHref
|
||||
>
|
||||
<a
|
||||
href="[workspaceSlug]/[appSlug]/functions/[functionId]"
|
||||
className={clsx(
|
||||
'flex cursor-pointer flex-row border-b py-2.5',
|
||||
folder.nestedLevel && 'ml-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<Image
|
||||
src={`/assets/functions/${func.lang}.svg`}
|
||||
alt={`Logo of ${func.lang}`}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className="pl-2 font-medium"
|
||||
size="small"
|
||||
>
|
||||
{func.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-row">
|
||||
<div className={clsx('flex w-52 self-center')}>
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className=""
|
||||
size="tiny"
|
||||
>
|
||||
{func.formattedCreatedAt || '-'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-16 self-center">
|
||||
<Status status={StatusEnum.Live}>Live</Status>
|
||||
|
||||
<ChevronRightIcon className="middl ml-2 h-4 w-4 cursor-pointer self-center" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-10 max-w-6xl">
|
||||
<div className="text-center">
|
||||
<Text size="tiny" color="greyscaleDark" className="font-medium">
|
||||
Base URL for function endpoints is{' '}
|
||||
{generateAppServiceUrl(
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
'functions',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
FunctionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import { FunctionsLogsTerminalPage } from '@/components/applications/functions/FunctionLogsTerminalFromPage';
|
||||
import type { Func } from '@/components/applications/functions/normalizeFunctionMetadata';
|
||||
import { normalizeFunctionMetadata } from '@/components/applications/functions/normalizeFunctionMetadata';
|
||||
import { LoadingScreen } from '@/components/common/LoadingScreen';
|
||||
import Help from '@/components/icons/Help';
|
||||
import Container from '@/components/layout/Container';
|
||||
import ProjectLayout from '@/components/layout/ProjectLayout';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWorkspacesAndApplications';
|
||||
import { Text } from '@/ui/Text';
|
||||
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
|
||||
import { yieldFunction } from '@/utils/helpers';
|
||||
import { useGetAppFunctionsMetadataQuery } from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function FunctionDetailsPage() {
|
||||
const { currentApplication, currentWorkspace } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
useGetAllUserWorkspacesAndApplications(false);
|
||||
|
||||
const { data, loading, error } = useGetAppFunctionsMetadataQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
});
|
||||
|
||||
const [currentFunction, setCurrentFunction] = useState<Func | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// currentFunction will be null until we get data back from remote and we set it to be the function we're looking for.
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const appFunctions = normalizeFunctionMetadata(data?.app.metadataFunctions);
|
||||
setCurrentFunction(yieldFunction(appFunctions, router));
|
||||
}, [data, router]);
|
||||
|
||||
if (!currentApplication || !currentWorkspace || loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message ||
|
||||
'An unexpected error has ocurred. Please try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentFunction) {
|
||||
return (
|
||||
<Container>
|
||||
<h1 className="text-4xl font-semibold text-greyscaleDark">Not found</h1>
|
||||
<p className="text-sm text-greyscaleGrey">
|
||||
This function does not exist.
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<div className="flex place-content-between">
|
||||
<div className="flex flex-row items-center py-1">
|
||||
<Image
|
||||
src={`/assets/functions/${
|
||||
currentFunction.name.split('.')[1]
|
||||
}.svg`}
|
||||
alt={`Logo of ${currentFunction.name.split('.')[1]}`}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
variant="body"
|
||||
className="ml-2 font-medium"
|
||||
size="big"
|
||||
>
|
||||
{currentFunction.name}
|
||||
</Text>
|
||||
<a
|
||||
className="ml-2 text-xs font-medium text-greyscaleGrey"
|
||||
href={`${generateAppServiceUrl(
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
'functions',
|
||||
)}${currentFunction?.route}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{`${generateAppServiceUrl(
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
'functions',
|
||||
)}${currentFunction?.route}`}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container className="pt-10">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<div className="flex">
|
||||
<Text size="large" className="font-medium" color="greyscaleDark">
|
||||
Log
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Text
|
||||
size="tiny"
|
||||
className="self-center font-medium"
|
||||
color="greyscaleDark"
|
||||
>
|
||||
Awaiting new requests…
|
||||
</Text>
|
||||
<a
|
||||
href="https://docs.nhost.io/platform/serverless-functions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Help className="h-7 w-7" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<FunctionsLogsTerminalPage functionName={currentFunction?.path} />
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
FunctionDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useUpdateAppMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import CheckIcon from '@/ui/v2/icons/CheckIcon';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import CheckIcon from '@/ui/v2/icons/CheckIcon';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { updateOwnCache } from '@/utils/updateOwnCache';
|
||||
@@ -53,6 +53,7 @@ export default function SettingsGeneralPage() {
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
variables: { appId: currentApplication?.id },
|
||||
});
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<ProjectNameValidationSchema>({
|
||||
@@ -69,6 +70,14 @@ export default function SettingsGeneralPage() {
|
||||
const { register, formState } = form;
|
||||
|
||||
const handleProjectNameChange = async (data: ProjectNameValidationSchema) => {
|
||||
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `updating: true`.
|
||||
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
|
||||
// i.e. redirecting to 404 if there's no workspace/project with that slug.
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, updating: true },
|
||||
});
|
||||
|
||||
const newProjectSlug = slugifyString(data.name);
|
||||
|
||||
if (newProjectSlug.length < 1 || newProjectSlug.length > 32) {
|
||||
@@ -100,8 +109,13 @@ export default function SettingsGeneralPage() {
|
||||
toastStyleProps,
|
||||
);
|
||||
try {
|
||||
await client.refetchQueries({ include: ['getOneUser'] });
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
form.reset(undefined, { keepValues: true, keepDirty: false });
|
||||
await router.push(
|
||||
`/${currentWorkspace.slug}/${newProjectSlug}/settings/general`,
|
||||
);
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
error.message || 'Error while trying to update application cache',
|
||||
|
||||
121
dashboard/src/utils/__generated__/graphql.ts
generated
121
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -17215,6 +17215,27 @@ export type GetDeploymentsQueryVariables = Exact<{
|
||||
|
||||
export type GetDeploymentsQuery = { __typename?: 'query_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
|
||||
|
||||
export type ScheduledOrPendingDeploymentsSubSubscriptionVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ScheduledOrPendingDeploymentsSubSubscription = { __typename?: 'subscription_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
|
||||
|
||||
export type LatestLiveDeploymentSubSubscriptionVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type LatestLiveDeploymentSubSubscription = { __typename?: 'subscription_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
|
||||
|
||||
export type InsertDeploymentMutationVariables = Exact<{
|
||||
object: Deployments_Insert_Input;
|
||||
}>;
|
||||
|
||||
|
||||
export type InsertDeploymentMutation = { __typename?: 'mutation_root', insertDeployment?: { __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null } | null };
|
||||
|
||||
export type GetDeploymentsSubSubscriptionVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
limit: Scalars['Int'];
|
||||
@@ -19147,6 +19168,106 @@ export type GetDeploymentsQueryResult = Apollo.QueryResult<GetDeploymentsQuery,
|
||||
export function refetchGetDeploymentsQuery(variables: GetDeploymentsQueryVariables) {
|
||||
return { query: GetDeploymentsDocument, variables: variables }
|
||||
}
|
||||
export const ScheduledOrPendingDeploymentsSubDocument = gql`
|
||||
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
|
||||
deployments(
|
||||
where: {deploymentStatus: {_in: ["PENDING", "SCHEDULED"]}, appId: {_eq: $appId}}
|
||||
) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
${DeploymentRowFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useScheduledOrPendingDeploymentsSubSubscription__
|
||||
*
|
||||
* To run a query within a React component, call `useScheduledOrPendingDeploymentsSubSubscription` and pass it any options that fit your needs.
|
||||
* When your component renders, `useScheduledOrPendingDeploymentsSubSubscription` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useScheduledOrPendingDeploymentsSubSubscription({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useScheduledOrPendingDeploymentsSubSubscription(baseOptions: Apollo.SubscriptionHookOptions<ScheduledOrPendingDeploymentsSubSubscription, ScheduledOrPendingDeploymentsSubSubscriptionVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useSubscription<ScheduledOrPendingDeploymentsSubSubscription, ScheduledOrPendingDeploymentsSubSubscriptionVariables>(ScheduledOrPendingDeploymentsSubDocument, options);
|
||||
}
|
||||
export type ScheduledOrPendingDeploymentsSubSubscriptionHookResult = ReturnType<typeof useScheduledOrPendingDeploymentsSubSubscription>;
|
||||
export type ScheduledOrPendingDeploymentsSubSubscriptionResult = Apollo.SubscriptionResult<ScheduledOrPendingDeploymentsSubSubscription>;
|
||||
export const LatestLiveDeploymentSubDocument = gql`
|
||||
subscription LatestLiveDeploymentSub($appId: uuid!) {
|
||||
deployments(
|
||||
where: {deploymentStatus: {_eq: "DEPLOYED"}, appId: {_eq: $appId}}
|
||||
order_by: {deploymentEndedAt: desc}
|
||||
limit: 1
|
||||
offset: 0
|
||||
) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
${DeploymentRowFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useLatestLiveDeploymentSubSubscription__
|
||||
*
|
||||
* To run a query within a React component, call `useLatestLiveDeploymentSubSubscription` and pass it any options that fit your needs.
|
||||
* When your component renders, `useLatestLiveDeploymentSubSubscription` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useLatestLiveDeploymentSubSubscription({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useLatestLiveDeploymentSubSubscription(baseOptions: Apollo.SubscriptionHookOptions<LatestLiveDeploymentSubSubscription, LatestLiveDeploymentSubSubscriptionVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useSubscription<LatestLiveDeploymentSubSubscription, LatestLiveDeploymentSubSubscriptionVariables>(LatestLiveDeploymentSubDocument, options);
|
||||
}
|
||||
export type LatestLiveDeploymentSubSubscriptionHookResult = ReturnType<typeof useLatestLiveDeploymentSubSubscription>;
|
||||
export type LatestLiveDeploymentSubSubscriptionResult = Apollo.SubscriptionResult<LatestLiveDeploymentSubSubscription>;
|
||||
export const InsertDeploymentDocument = gql`
|
||||
mutation InsertDeployment($object: deployments_insert_input!) {
|
||||
insertDeployment(object: $object) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
${DeploymentRowFragmentDoc}`;
|
||||
export type InsertDeploymentMutationFn = Apollo.MutationFunction<InsertDeploymentMutation, InsertDeploymentMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useInsertDeploymentMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useInsertDeploymentMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useInsertDeploymentMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [insertDeploymentMutation, { data, loading, error }] = useInsertDeploymentMutation({
|
||||
* variables: {
|
||||
* object: // value for 'object'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useInsertDeploymentMutation(baseOptions?: Apollo.MutationHookOptions<InsertDeploymentMutation, InsertDeploymentMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InsertDeploymentMutation, InsertDeploymentMutationVariables>(InsertDeploymentDocument, options);
|
||||
}
|
||||
export type InsertDeploymentMutationHookResult = ReturnType<typeof useInsertDeploymentMutation>;
|
||||
export type InsertDeploymentMutationResult = Apollo.MutationResult<InsertDeploymentMutation>;
|
||||
export type InsertDeploymentMutationOptions = Apollo.BaseMutationOptions<InsertDeploymentMutation, InsertDeploymentMutationVariables>;
|
||||
export const GetDeploymentsSubDocument = gql`
|
||||
subscription getDeploymentsSub($id: uuid!, $limit: Int!, $offset: Int!) {
|
||||
deployments(
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type {
|
||||
FinalFunction,
|
||||
Func,
|
||||
} from '@/components/applications/functions/normalizeFunctionMetadata';
|
||||
import features from '@/data/features.json';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import slugify from 'slugify';
|
||||
import { LOCAL_BACKEND_URL } from './env';
|
||||
import type { DeploymentRowFragment } from './__generated__/graphql';
|
||||
@@ -89,26 +84,6 @@ export function emptyWorkspace() {
|
||||
};
|
||||
}
|
||||
|
||||
export function yieldFunction(
|
||||
functionsToSearch: FinalFunction[],
|
||||
router: NextRouter,
|
||||
): Func {
|
||||
let functionToReturn: Func = null;
|
||||
|
||||
functionsToSearch.forEach((currentFolder) => {
|
||||
currentFolder.funcs.forEach((currentFunction) => {
|
||||
if (
|
||||
!functionToReturn &&
|
||||
currentFunction.functionName === router.query.functionId
|
||||
) {
|
||||
functionToReturn = currentFunction;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return functionToReturn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the state number of the application to its string equivalent.
|
||||
* @param appStatus The current state of the application.
|
||||
|
||||
@@ -13,6 +13,9 @@ module.exports = {
|
||||
...defaultTheme.screens,
|
||||
},
|
||||
extend: {
|
||||
animation: {
|
||||
'spin-reverse': 'spin 1.5s linear infinite reverse',
|
||||
},
|
||||
colors: {
|
||||
primary: '#0052cd',
|
||||
'primary-light': '#ebf3ff',
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.0.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e146d32e: chore(deps): update dependency @types/react to v18.0.27
|
||||
- 5b65cac9: updated authentication documentation
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e6dad4d6: Added remote schemas
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Email Templates
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
Nhost Authentication sends out transactional emails as part of the authentication service. These emails can be modified using email templates.
|
||||
Nhost Auth sends out transactional emails as part of the authentication service. These emails can be modified using email templates.
|
||||
|
||||
The following email templates are available:
|
||||
|
||||
@@ -70,8 +70,6 @@ As you see, the format is:
|
||||
nhost/emails/{two-letter-language-code}/{email-template}/[subject.txt, body.html]
|
||||
```
|
||||
|
||||
Default templates for English (`en`) and French (`fr`) are automatically generated when the project is initialized with the [CLI](/cli).
|
||||
|
||||
## Languages
|
||||
|
||||
The user's language is what decides what template to send. The user's language is stored in the `auth.users` table in the `locale` column. This `locale` column contains a two-letter language code in [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) format.
|
||||
|
||||
@@ -22,3 +22,65 @@ Nhost Authentication lets you authenticate users using different sign-in methods
|
||||
- [Spotify](/authentication/sign-in-with-spotify)
|
||||
- [Twitch](/authentication/sign-in-with-twitch)
|
||||
- [WorkOS](/authentication/sign-in-with-workos)
|
||||
|
||||
## Client URL
|
||||
|
||||
Client URL is the URL of your frontend application. The Client URL is used to redirect the user after interacting with any authentication operation, like signing in or resetting their password.
|
||||
|
||||
## Allowed Redirect URLs
|
||||
|
||||
Allowed Redirect URLs are the URLs of your frontend application that are allowed to redirect the user after interacting with any authentication operation, like signing in or resetting their password. This is useful if you have multiple frontend applications that are using the same Nhost backend or if you want to redirect the user to a specific URL after interacting with an authentication operation.
|
||||
|
||||
As an example, for a staging project, you can set the Client URL to `https://staging.example.com` and Allowed Redirect URLs to `https://*.vercel.app`. This way, the user can be redirected to any Vercel deployment of your frontend application.
|
||||
|
||||
## Allowed Emails and Domains
|
||||
|
||||
Allowed Emails and Domains are used to restrict the sign-up an sign-in process to specific email addresses and domains.
|
||||
|
||||
If both allowed emails and allowed domains are set a user can only sign up if their email address matches one of the allowed emails or one of the allowed domains.
|
||||
|
||||
## Blocked Emails and Domains
|
||||
|
||||
Blocked Emails and Domains are used to block specific email addresses and domains from signing up and singin in.
|
||||
|
||||
Note that even if a user's email address matches any allowed email or domain, they will still be blocked if their email address matches any blocked email or domain.
|
||||
|
||||
## Multi-factor Authentication
|
||||
|
||||
By enabling Multi-factor Authentication (MFA), you can allow users to verify their identity using a second factor during the sign-in process. We currently support Authenticator Apps (TOTP) for MFA.
|
||||
|
||||
Once MFA is enabled, a user can enable MFA for their account by scanning a QR code with their Authenticator App. After that, they will be prompted to enter a code generated by their Authenticator App during the sign-in process.
|
||||
|
||||
We'll be adding more support in our SDKs and documentation around MFA soon.
|
||||
|
||||
## Gravatar
|
||||
|
||||
If Gravatar is enabled, Nhost Auth will use the user's email address to fetch their Gravatar profile picture. If the user doesn't have a Gravatar profile picture, a default image will be used.
|
||||
|
||||
There are two options for Gravatars:
|
||||
|
||||
### Default Image
|
||||
|
||||
If the user doesn't have a Gravatar profile picture, a default image will be used. You can choose between the following options:
|
||||
|
||||
- `404`: Do not load any image if none is associated with the email hash, instead return an HTTP 404 (File Not Found) response.
|
||||
- `mp`: (mystery-person) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
|
||||
- `identicon`: a geometric pattern based on an email hash.
|
||||
- `monsterid`: a generated 'monster' with different colors, faces, etc.
|
||||
- `wavatar`: generated faces with differing features and backgrounds.
|
||||
- `retro`: awesome generated, 8-bit arcade-style pixelated faces.
|
||||
- `robohash`: a generated robot with different colors, faces, etc.
|
||||
- `blank`: a transparent PNG image.
|
||||
|
||||
### Rating
|
||||
|
||||
Gravatar images are rated by default. You can choose between the following options:
|
||||
|
||||
- `g`: suitable for display on all websites with any audience type.
|
||||
- `pg`: may contain rude gestures, provocatively dressed individuals, lesser swear words or mild violence.
|
||||
- `r`: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
|
||||
- `x`: may contain hardcore sexual imagery or extremely disturbing violence.
|
||||
|
||||
## Disable New Users
|
||||
|
||||
If set, newly registered users are disabled and won't be able to sign in. This is useful if you want to manually approve new users before they can sign in.
|
||||
|
||||
@@ -5,13 +5,11 @@ slug: /authentication/sign-in-with-email-and-password
|
||||
image: /img/og/sign-in-with-email-and-password.png
|
||||
---
|
||||
|
||||
Follow this guide to sign in users with email and password.
|
||||
|
||||
The email and password sign-in method is enabled by default for all Nhost projects.
|
||||
The Email and Password sign-in method is always enabled for all Nhost projects.
|
||||
|
||||
## Sign Up
|
||||
|
||||
Users must first sign up to be able to sign in with Email and Password.
|
||||
Users must first sign up to be able to sign in.
|
||||
|
||||
**Example:** Sign up users using the [Nhost JavaScript client](/reference/javascript).
|
||||
|
||||
@@ -26,7 +24,7 @@ If you've turned on email verification in your project's **Authentication Settin
|
||||
|
||||
## Sign In
|
||||
|
||||
Once a user has been signed up (and optionally verified), you can sign them in.
|
||||
After the user has successfully signed up, they can sign in.
|
||||
|
||||
**Example:** Sign in users using the [Nhost JavaScript client](/reference/javascript).
|
||||
|
||||
@@ -37,8 +35,10 @@ await nhost.auth.signIn({
|
||||
})
|
||||
```
|
||||
|
||||
## Verified Emails
|
||||
## Email Verification
|
||||
|
||||
You can decide if only verified emails should be able to sign in or not. Modify the **Only allow users with verified emails to sign in.** setting in the **Authentication Settings** section under **Users** in your Nhost project.
|
||||
If you want to require users to verify their email before they can sign in, you can enable this under **Settings -> Sign-In Methods -> Email and Password** by checking the **Require Verified Emails** checkbox.
|
||||
|
||||
An email-verification email is automatically sent to the user during sign-up if your project only allows to sign in users with verified emails. You can also manually send the verification email to the user using [`nhost.auth.sendVerificationEmail()`](/reference/javascript/auth/send-verification-email).
|
||||
If **Require Verified Emails** is enabled, users automatically get a verification email when they sign up. The user must click the verification link in the email before they can sign in. It's possible to edit the ["email-verify" email template](/authentication/email-templates).
|
||||
|
||||
It's possible to manually send a verification email to the user using [`nhost.auth.sendVerificationEmail()`](/reference/javascript/auth/send-verification-email).
|
||||
|
||||
@@ -5,15 +5,15 @@ slug: /authentication/sign-in-with-magic-link
|
||||
image: /img/og/sign-in-with-magic-link.png
|
||||
---
|
||||
|
||||
Follow this guide to sign in users with Magic Link, also called passwordless email.
|
||||
Nhost allows you to sign in users with a Magic Link, which is a way to sign in users so they don't have to remember a password.
|
||||
|
||||
The Magic Link sign-in method enables you to sign in users using an email address, without requiring a password.
|
||||
When users sign in using this sign-in method, they'll enter their email address and then receive an email with a (magic) link. When the user clicks on the (magic) link, they get automatically signed in to your app.
|
||||
|
||||
## Setup
|
||||
The sign-in method is called Magic Link because the user gets "magically" signed in without having to enter a password.
|
||||
|
||||
Enable the Magic Link sign-in method in the Nhost dashboard under **Users** -> **Authentication Settings** -> **Magic Link**.
|
||||
## Configuration
|
||||
|
||||

|
||||
Enable the Magic Link sign-in method in the Nhost dashboard under **Settings -> Sign-In Methods -> Magic Link**.
|
||||
|
||||
## Sign In
|
||||
|
||||
@@ -30,4 +30,10 @@ nhost.auth.signIn({
|
||||
})
|
||||
```
|
||||
|
||||
If you want to change the email for your magic link emails, you can do so by changing the [email templates](/authentication/email-templates).
|
||||
There is no sign up method for Magic Link. Users will be automatically created when they sign in for the first time.
|
||||
|
||||
Users who have signed up with email and password can also sign in with Magic Link.
|
||||
|
||||
## Email
|
||||
|
||||
It's possible to edit the ["signin-passwordless" email template](/authentication/email-templates).
|
||||
|
||||
@@ -7,11 +7,11 @@ image: /img/og/sign-in-with-phone-number-sms.png
|
||||
|
||||
Follow this guide to sign in users with a phone number (SMS).
|
||||
|
||||
## Setup
|
||||
## Configuration
|
||||
|
||||
You need a [Twilio account](https://www.twilio.com/try-twilio) to use this feature because all SMS are sent through Twilio.
|
||||
|
||||
Enable the Phone Number (SMS) sign-in method in the Nhost dashboard under **Users** -> **Authentication Settings** -> **Passwordless SMS**.
|
||||
Enable the Phone Number (SMS) sign-in method in the Nhost dashboard under **Settings -> Sign-In Methods -> Phone Number (SMS)**.
|
||||
|
||||
You need to insert the following settings in the Nhost dashboard from Twilio:
|
||||
|
||||
@@ -19,10 +19,6 @@ You need to insert the following settings in the Nhost dashboard from Twilio:
|
||||
- Auth Token
|
||||
- Messaging Service SID (or a Twilio phone number)
|
||||
|
||||
<video width="99%" autoPlay muted loop controls="true" style={{ marginBottom: '15px' }}>
|
||||
<source src="/videos/enable-sms-sign-in.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
## Sign In
|
||||
|
||||
To sign in users with a phone number is a two-step process:
|
||||
@@ -44,7 +40,7 @@ await nhost.auth.signIn({
|
||||
})
|
||||
```
|
||||
|
||||
The first time a user signs in using a phone number, the user is created. That means you don't need to sign up the user before signin in the user.
|
||||
The first time a user signs in using a phone number, the user is created. That means you don't need to sign up users before signing in users.
|
||||
|
||||
:::info
|
||||
|
||||
|
||||
@@ -16,81 +16,105 @@ Examples of security keys:
|
||||
|
||||
You can read more about this feature in our [blog post](https://nhost.io/blog/webauthn-sign-in-method)
|
||||
|
||||
## Setup
|
||||
## Configuration
|
||||
|
||||
Enable the Security Key sign-in method in the Nhost dashboard under **Users** -> **Authentication Settings** -> **Security Keys**.
|
||||
Enable the Security Key sign-in method in the Nhost dashboard under **Settings -> Sign-In Methods -> Security Keys**.
|
||||
|
||||
You need to make sure you also set a valid client URL under **Users** -> **Authentication Settings** -> **Client URL**.
|
||||
|
||||
<video width="99%" autoPlay muted loop controls="true" style={{ marginBottom: '15px' }}>
|
||||
<source src="/videos/enable-security-keys-sign-in.mp4" type="video/mp4" />
|
||||
</video>
|
||||
You need to make sure you also set a valid client URL under **Settings -> Authentication -> Client URL**.
|
||||
|
||||
## Sign Up
|
||||
|
||||
Signing up with a security key uses the same method as signing up with an email and a password. Instead of a `password` parameter, you need to set the `securityKey` parameter to `true`:
|
||||
Users must use an email address to sign up with a security key.
|
||||
|
||||
Here's an example of how to sign up a user with a security key with our [JavaScript SDK](/reference/javascript):
|
||||
|
||||
**Example:**: Sign up with a security key:
|
||||
|
||||
```tsx
|
||||
const { error, session } = await nhost.auth.signUp({
|
||||
email: 'joe@example.com',
|
||||
securityKey: true
|
||||
})
|
||||
|
||||
// Something unexpected happened, for instance, the user canceled the process
|
||||
if (error) {
|
||||
// Something unexpected happened, for instance, the user canceled their registration
|
||||
console.log(error)
|
||||
} else if (session) {
|
||||
// Sign up is complete!
|
||||
console.log(session.user)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
// if there is no error and no session, the user needs to verify their email address.
|
||||
if (!session) {
|
||||
console.log(
|
||||
'You need to verify your email address by clicking the link in the email we sent you.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
//Sign-up is complete!
|
||||
console.log(session.user)
|
||||
```
|
||||
|
||||
## Sign In
|
||||
|
||||
Once a user added a security key, they can use it to sign in:
|
||||
Once a user signed up with a security key, and verfied their email if needed, they can use it to sign in.
|
||||
|
||||
**Example:** Sign in with a security key:
|
||||
|
||||
```tsx
|
||||
const { error, session } = await nhost.auth.signIn({
|
||||
email,
|
||||
email: 'joe@example.com',
|
||||
securityKey: true
|
||||
})
|
||||
if (session) {
|
||||
// User is signed in
|
||||
} else {
|
||||
|
||||
// Something unexpected happened, for instance, the user canceled the process
|
||||
if (error) {
|
||||
console.log(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Something unexpected happened
|
||||
console.log(error)
|
||||
return
|
||||
}
|
||||
|
||||
// User is signed in
|
||||
```
|
||||
|
||||
## Add a Security Key
|
||||
|
||||
Any signed-in user with a valid email can add a security key when the feature is enabled. For instance, someone who signed up with an email and a password can add a security key and thus use it for their later sign-in!
|
||||
Any signed-in user with a verified email can add a security key to their user account. Once a security key is added, the user can use it their email and the security key to sign in.
|
||||
|
||||
Users can use multiple devices to sign in to their account. They can add as many security keys as they like.
|
||||
It's possible to add multiple security keys to a user account.
|
||||
|
||||
**Example:** Add a security key to a user account:
|
||||
|
||||
```tsx
|
||||
const { key, error } = await nhost.auth.addSecurityKey()
|
||||
if (key) {
|
||||
// Successfully added a new security key
|
||||
console.log(key.id)
|
||||
} else {
|
||||
// Somethine unexpected happened
|
||||
|
||||
// Something unexpected happened
|
||||
if (error) {
|
||||
console.log(error)
|
||||
return
|
||||
}
|
||||
|
||||
// Successfully added a new security key
|
||||
console.log(key.id)
|
||||
```
|
||||
|
||||
A nickname can be added for each security key to make them easy to identify:
|
||||
A nickname can be associated with each security key to make it easier to manage security keys in the future.
|
||||
|
||||
**Example:** Add a security key with a nickname:
|
||||
|
||||
```tsx
|
||||
await nhost.auth.addSecurityKey('my macbook')
|
||||
await nhost.auth.addSecurityKey('iPhone')
|
||||
```
|
||||
|
||||
## List or Remove Security Keys
|
||||
|
||||
To list and to remove security keys can be achieved over GraphQL after setting the correct Hasura permissions to the `auth.security_keys` table:
|
||||
To list and remove security keys, use GraphQL and set permissions on the `auth.security_keys` table:
|
||||
|
||||
**Example:** Get all security keys for a user:
|
||||
|
||||
```graphql
|
||||
query securityKeys($userId: uuid!) {
|
||||
@@ -99,6 +123,11 @@ query securityKeys($userId: uuid!) {
|
||||
nickname
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** Remove a security key:
|
||||
|
||||
```graphql
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
|
||||
@@ -9,7 +9,7 @@ Nhost Authentication supports the following sign-in methods:
|
||||
- [Email and Password](/authentication/sign-in-with-email-and-password)
|
||||
- [Magic Link](/authentication/sign-in-with-magic-link)
|
||||
- [Phone Number (SMS)](/authentication/sign-in-with-phone-number-sms)
|
||||
- [Security Keys (WebAuthn)](/authentication/sign-in-with-phone-number-sms)
|
||||
- [Security Keys (WebAuthn)](/authentication/sign-in-with-security-keys)
|
||||
- [Apple](/authentication/sign-in-with-apple)
|
||||
- [Discord](/authentication/sign-in-with-discord)
|
||||
- [Facebook](/authentication/sign-in-with-facebook)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
---
|
||||
title: Social Providers Configuration
|
||||
sidebar_label: Social Providers Configuration
|
||||
title: Social Providers
|
||||
sidebar_label: Social Providers
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
## Enabling Social Sign-In Provider
|
||||
|
||||
To start with social sign-in, select your project in Nhost Dashboard and go to **Users** → **Authentication Settings**.
|
||||
|
||||
You need to set the Client ID and Client Secret for each provider that you want to enable.
|
||||
To start with social sign-in, select your project in Nhost Dashboard and go to **Settings -> Sign-In Methods**.
|
||||
|
||||
## Implementing sign-in experience
|
||||
|
||||
Use the [Nhost JavaScript SDK](/reference/javascript) and the `signIn()` method to implement social sign-in for your project.
|
||||
|
||||
Here's an example of how to implement sign-in with GitHub:
|
||||
**Example**: Sign in a user with [GitHub](/authentication/sign-in-with-github).
|
||||
|
||||
```js
|
||||
nhost.auth.signIn({
|
||||
@@ -22,19 +20,21 @@ nhost.auth.signIn({
|
||||
})
|
||||
```
|
||||
|
||||
Users are redirected to your Nhost project's **client URL** by default. By default, your Nhost project's client URL is set to `http://localhost:3000`. You can change the value of your client URL in the Nhost console by going to **Users** → **Authentication Settings** → **Client URL**.
|
||||
During the sign-in flow, the user is redirected to the provider's website to authenticate. After the user authenticates, they are redirected back to your Nhost project's [**Client URL**](/authentication#client-url) by default. You can change where the user gets redirected to after authentication by passing the `redirectTo` option.
|
||||
|
||||
Here is an example of how to redirect to another host or path:
|
||||
**Example:** Redirect the user to `https://staging.example.com/welcome` after they complete the sign-in flow.
|
||||
|
||||
```js
|
||||
nhost.auth.signIn({
|
||||
provider: '<provider>'
|
||||
provider: 'github'
|
||||
options: {
|
||||
redirectTo: "<host>/<slug>" // Example: "https://example.com/dashboard"
|
||||
redirectTo: "https://staging.example.com/welcome",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
In the example above, it's important to note that the `redirectTo` URL must be part of the [Allowed Redirect URLs](/authentication#allowed-redirect-urls) of your Nhost project.
|
||||
|
||||
## Provider OAuth scopes
|
||||
|
||||
Scopes are a mechanism in OAuth to allow or limit an application's access to a user's account.
|
||||
|
||||
@@ -5,35 +5,7 @@ sidebar_position: 1
|
||||
image: /img/og/users.png
|
||||
---
|
||||
|
||||
Users are stored in the database in the `auth.users` table.
|
||||
|
||||
## Get User Information using GraphQL
|
||||
|
||||
**Example:** Get all users.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
users {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** Get a single user.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
user(id: "<user-id>") {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
Users are stored in the `auth.users` table in the [database](/database).
|
||||
|
||||
## Creating Users
|
||||
|
||||
@@ -133,6 +105,34 @@ await nhost.auth.signUp({
|
||||
})
|
||||
```
|
||||
|
||||
## Get User Information using GraphQL
|
||||
|
||||
**Example:** Get all users.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
users {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** Get a single user.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
user(id: "<user-id>") {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Import Users
|
||||
|
||||
If you have users in a different system, you can import them into Nhost. When importing users you should insert the users directly into the database instead of using the authentication endpoints (`/signup/email-password`) to avoid sending unnecessary transactional emails.
|
||||
|
||||
4
docs/docs/graphql/remote-schemas/_category_.json
Normal file
4
docs/docs/graphql/remote-schemas/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Remote Schemas",
|
||||
"position": 11
|
||||
}
|
||||
252
docs/docs/graphql/remote-schemas/stripe.mdx
Normal file
252
docs/docs/graphql/remote-schemas/stripe.mdx
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: Stripe GraphQL API
|
||||
sidebar_label: Stripe
|
||||
sidebar_position: 2
|
||||
image: /img/og/graphql.png
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
This package creates a Stripe GraphQL API, allowing for interaction with data at Stripe.
|
||||
|
||||
Here's an example of how to use the Stripe GraphQL API to get a list of invoices for a specific Stripe customer:
|
||||
|
||||
<Tabs >
|
||||
<TabItem value="request" label="Request" default>
|
||||
|
||||
```graphql
|
||||
query {
|
||||
stripe {
|
||||
customer(id: "cus_xxx") {
|
||||
id
|
||||
name
|
||||
invoices {
|
||||
data {
|
||||
id
|
||||
created
|
||||
paid
|
||||
hostedInvoiceUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="response" label="Response">
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"stripe": {
|
||||
"customer": {
|
||||
"id": "cus_xxx",
|
||||
"name": "joe@example.com",
|
||||
"invoices": {
|
||||
"data": [
|
||||
{
|
||||
"id": "in_1MUmwnCCF9wuB4xxxxxxxx",
|
||||
"created": 1674806769,
|
||||
"paid": true,
|
||||
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_xxxxxxx/test_YWNjdF8xS25xV1lDQ0Y5d3VCNGZYLF9ORkhWxxxxxxxxxxxx?s=ap"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
It's recommended to add the Stripe GraphQL API as a [Remote Schema in Hasura](https://hasura.io/docs/latest/remote-schemas/index/) and connect data from your database with data in Stripe. By doing so, it's possible to request data from your database and Stripe in a single GraphQL query.
|
||||
|
||||
Here's an example of how to use the Stripe GraphQL API to get a list of invoices for a specific Stripe customer. Note that the user data is fetched from your database and the Stripe customer data is fetched from Stripe:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
users {
|
||||
# User in your database
|
||||
id
|
||||
displayName
|
||||
userData {
|
||||
stripeCustomerId # Customer's Stripe Customer Id
|
||||
stripeCustomer {
|
||||
# Data from Stripe
|
||||
id
|
||||
name
|
||||
paymentMethods {
|
||||
id
|
||||
card {
|
||||
brand
|
||||
last4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Get Started
|
||||
|
||||
Install the package:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="npm" label="npm" default>
|
||||
|
||||
```bash
|
||||
npm install @nhost/stripe-graphql-js
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yarn" label="Yarn">
|
||||
|
||||
```bash
|
||||
yarn install @nhost/stripe-graphql-js
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pnpm" label="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @nhost/stripe-graphql-js
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Serverless Function
|
||||
|
||||
Create a new [Serverless Function](/serverless-functions): `functions/graphql/stripe.ts`:
|
||||
|
||||
```ts
|
||||
import { createStripeGraphQLServer } from '@nhost/stripe-graphql-js'
|
||||
|
||||
const server = createStripeGraphQLServer()
|
||||
|
||||
export default server
|
||||
```
|
||||
|
||||
> You can run the Stripe GraphQL API in any Node.js environment because it's built using [GraphQL Yoga](https://github.com/dotansimha/graphql-yoga).
|
||||
|
||||
## Stripe Secret Key
|
||||
|
||||
Add `STRIPE_SECRET_KEY` as an environment variable.
|
||||
|
||||
If you're using Nhost, add `STRIPE_SECRET_KEY` to `.env.development` like this:
|
||||
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_***
|
||||
```
|
||||
|
||||
And add the production key (`sk_live_***`) to [environment variables](/platform/environment-variables) in the Nhost dashboard.
|
||||
|
||||
Learn more about [Stripe API keys](https://stripe.com/docs/keys#obtain-api-keys).
|
||||
|
||||
## Start Nhost
|
||||
|
||||
```
|
||||
nhost up
|
||||
```
|
||||
|
||||
Learn more about the [Nhost CLI](/cli).
|
||||
|
||||
## Test
|
||||
|
||||
Test the Stripe GraphQL API in the browser:
|
||||
|
||||
[http://localhost:1337/v1/functions/graphql/stripe](http://localhost:1337/v1/functions/graphql/stripe)
|
||||
|
||||
## Remote Schema
|
||||
|
||||
Add the Stripe GraphQL API as a Remote Schema in Hasura.
|
||||
|
||||
**URL**
|
||||
|
||||
```
|
||||
{{NHOST_FUNCTIONS_URL}}/graphql/stripe
|
||||
```
|
||||
|
||||
**Headers**
|
||||
|
||||
```
|
||||
x-nhost-webhook-secret: NHOST_WEBHOOK_SECRET (From env var)
|
||||
```
|
||||
|
||||
> The `NHOST_WEBHOOK_SECRET` is used to verify that the request is coming from Nhost. The environment variable is a [system environment variable](/platform/environment-variables#system-environment-variables) and is always available.
|
||||
|
||||

|
||||
|
||||
## Permissions
|
||||
|
||||
Here's a minimal example without any custom permissions. Only requests using the `x-hasura-admin-secret` header will work:
|
||||
|
||||
```js
|
||||
const server = createStripeGraphQLServer()
|
||||
```
|
||||
|
||||
For more granular permissions, you can pass an `isAllowed` function to the `createStripeGraphQLServer`. The `isAllowed` function takes a `stripeCustomerId` and [`context`](#context) as parameters and runs every time the GraphQL server makes a request to Stripe to get or modify data for a specific Stripe customer.
|
||||
|
||||
Here is an example of an `isAllowed` function:
|
||||
|
||||
```ts
|
||||
import { createStripeGraphQLServer } from '@nhost/stripe-graphql-js'
|
||||
|
||||
const isAllowed = (stripeCustomerId: string, context: Context) => {
|
||||
const { isAdmin, userClaims } = context
|
||||
|
||||
// allow all requests if they have a valid `x-hasura-admin-secret`
|
||||
if (isAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// get user id
|
||||
const userId = userClaims['x-hasura-user-id']
|
||||
|
||||
// check if the user is signed in
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
|
||||
// get more user information from the database
|
||||
const { user } = await gqlSDK.getUser({
|
||||
id: userId
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
// check if the user is part of a workspace with the `stripeCustomerId`
|
||||
return user.workspaceMembers.some((workspaceMember) => {
|
||||
return workspaceMember.workspace.stripeCustomerId === stripeCustomerId
|
||||
})
|
||||
}
|
||||
|
||||
const server = createStripeGraphQLServer({ isAllowed })
|
||||
|
||||
export default server
|
||||
```
|
||||
|
||||
### Context
|
||||
|
||||
The `context` object contains:
|
||||
|
||||
- `userClaims` - verified JWT claims from the user's access token.
|
||||
- `isAdmin` - `true` if the request was made using a valid `x-hasura-admin-secret` header.
|
||||
- `request` - [Fetch API Request object](https://developer.mozilla.org/en-US/docs/Web/API/Request) that represents the incoming HTTP request in platform-independent way. It can be useful for accessing headers to authenticate a user
|
||||
- `query` - the DocumentNode that was parsed from the GraphQL query string
|
||||
- `operationName` - the operation name selected from the incoming query
|
||||
- `variables` - the variables that were defined in the query
|
||||
- `extensions` - the extensions that were received from the client
|
||||
|
||||
Read more about the [default context from GraphQL Yoga](https://www.the-guild.dev/graphql/yoga-server/docs/features/context#default-context).
|
||||
|
||||
## Source Code
|
||||
|
||||
The source code is available on [GitHub](https://github.com/nhost/nhost/tree/main/integrations/stripe-graphql-js).
|
||||
@@ -103,7 +103,7 @@ You can install the Nhost Next.js SDK with:
|
||||
<TabItem value="npm" label="npm" default>
|
||||
|
||||
```bash
|
||||
npm install@nhost/nextjs graphql
|
||||
npm install @nhost/nextjs graphql
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -11,9 +11,9 @@ With Nhost, you can deploy Serverless Functions to execute custom code. Each Ser
|
||||
|
||||
Serverless functions can be used to handle [event triggers](/database/event-triggers), form submissions, integrations (e.g. Stripe, Slack, etc), and more.
|
||||
|
||||
## Creating a Serverless Function
|
||||
## Create a Serverless Function
|
||||
|
||||
Every `.js` (JavaScript) and `.ts` (TypeScript) file in the `functions/` folder of your Nhost project is its own Serverless Function.
|
||||
Every `.ts` (TypeScript) and `.js` (JavaScript) file in the `functions/` folder of your Nhost project is its own Serverless Function.
|
||||
|
||||
<Tabs groupId="language">
|
||||
<TabItem value="ts" label="TypeScript" default>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
BIN
docs/static/img/graphql/remote-schemas/stripe/remote-schema.png
vendored
Normal file
BIN
docs/static/img/graphql/remote-schemas/stripe/remote-schema.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
@@ -1,5 +1,14 @@
|
||||
# @nhost-examples/codegen-react-apollo
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
|
||||
- Updated dependencies [200e9f77]
|
||||
- @nhost/react@1.13.2
|
||||
- @nhost/react-apollo@4.13.2
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# GraphQL Code Generator Example with React and Apollo Client
|
||||
|
||||
This is an example repo for how to use GraphQL Code Generator together with:
|
||||
Todo app to show how to use:
|
||||
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Nhost](https://nhost.io/)
|
||||
- [React](https://reactjs.org/)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [GraphQL Code Generator](https://the-guild.dev/graphql/codegen)
|
||||
- [Apollo Client](https://www.apollographql.com/docs/react/)
|
||||
- [Nhost](http://nhost.io/)
|
||||
|
||||
This repo is a reference repo for the blog post: [How to use GraphQL Code Generator with React and Apollo](https://nhost.io/blog/how-to-use-graphql-code-generator-with-react-and-apollo).
|
||||
|
||||
@@ -13,42 +14,42 @@ This repo is a reference repo for the blog post: [How to use GraphQL Code Genera
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
```
|
||||
```sh
|
||||
git clone https://github.com/nhost/nhost
|
||||
cd nhost
|
||||
```
|
||||
|
||||
2. Install and build dependencies
|
||||
|
||||
```
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
3. Go to the Codegen React Apollo example folder
|
||||
3. Go to the example folder
|
||||
|
||||
```
|
||||
cd examples/codegen-react-apollo
|
||||
```sh
|
||||
cd examples/codegen-react-urql
|
||||
```
|
||||
|
||||
4. Terminal 1: Start Nhost
|
||||
|
||||
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
|
||||
|
||||
```sh
|
||||
nhost up
|
||||
nhost up -d
|
||||
```
|
||||
|
||||
5. Terminal 2: Run GraphQL Codegen
|
||||
5. Terminal 2: Start the React application
|
||||
|
||||
```sh
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## GraphQL Code Generators
|
||||
|
||||
To re-run the GraphQL Code Generators, run the following:
|
||||
|
||||
```
|
||||
pnpm codegen -w
|
||||
```
|
||||
|
||||
> `-w` runs [codegen in watch mode](https://www.the-guild.dev/graphql/codegen/docs/getting-started/development-workflow#watch-mode).
|
||||
|
||||
6. Terminal 3: Start the React application
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -2,13 +2,3 @@ schema:
|
||||
- http://localhost:1337/v1/graphql:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
documents:
|
||||
- 'src/**/*.graphql'
|
||||
generates:
|
||||
src/utils/__generated__/graphql.ts:
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-react-apollo'
|
||||
config:
|
||||
withRefetchFn: true
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
metadata_directory: metadata
|
||||
services:
|
||||
hasura:
|
||||
image: hasura/graphql-engine:v2.15.2
|
||||
environment:
|
||||
hasura_graphql_enable_remote_schema_permissions: false
|
||||
image: hasura/graphql-engine:v2.15.2
|
||||
minio:
|
||||
environment:
|
||||
minio_root_password: minioaccesskey123123
|
||||
minio_root_user: minioaccesskey123123
|
||||
postgres:
|
||||
environment:
|
||||
postgres_password: postgres
|
||||
postgres_user: postgres
|
||||
auth:
|
||||
image: nhost/hasura-auth:0.16.2
|
||||
image: nhost/hasura-auth:0.16.1
|
||||
storage:
|
||||
image: nhost/hasura-storage:0.3.0
|
||||
auth:
|
||||
webauthn:
|
||||
enabled: true
|
||||
rp_name: URQL
|
||||
access_control:
|
||||
email:
|
||||
allowed_email_domains: ''
|
||||
@@ -18,13 +29,13 @@ auth:
|
||||
url:
|
||||
allowed_redirect_urls: ''
|
||||
anonymous_users_enabled: false
|
||||
client_url: http://localhost:3000
|
||||
client_url: http://localhost:5173
|
||||
disable_new_users: false
|
||||
email:
|
||||
enabled: false
|
||||
passwordless:
|
||||
enabled: false
|
||||
signin_email_verified_required: true
|
||||
enabled: true
|
||||
signin_email_verified_required: false
|
||||
template_fetch_url: ''
|
||||
gravatar:
|
||||
default: ''
|
||||
@@ -117,11 +128,7 @@ auth:
|
||||
secure: false
|
||||
sender: hasura-auth@example.com
|
||||
user: user
|
||||
token:
|
||||
access:
|
||||
expires_in: 900
|
||||
refresh:
|
||||
expires_in: 43200
|
||||
access_token_expires_in: 315
|
||||
user:
|
||||
allowed_roles: user,me
|
||||
default_allowed_roles: user,me
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
connection_lifetime: 600
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
retries: 1
|
||||
retries: 20
|
||||
use_prepared_statements: true
|
||||
tables: "!include default/tables/tables.yaml"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user