Compare commits
20 Commits
timing
...
cli@1.34.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66bd45b444 | ||
|
|
c2cb28d500 | ||
|
|
a6a378c5a6 | ||
|
|
a3a3cf205d | ||
|
|
3fd2e63db3 | ||
|
|
f5956f1b2e | ||
|
|
f3b397b0d8 | ||
|
|
b7940087ee | ||
|
|
3dae655858 | ||
|
|
2aa269734b | ||
|
|
bc91836f83 | ||
|
|
6d8b243571 | ||
|
|
c9967b1a6d | ||
|
|
7f72aadff9 | ||
|
|
8faf9565bb | ||
|
|
7ac3f12852 | ||
|
|
184a3ed190 | ||
|
|
372c4e32d4 | ||
|
|
a68d261d8e | ||
|
|
55bda3f56b |
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -32,6 +32,7 @@ Where `PKG` is:
|
||||
- `deps`: For changes to dependencies
|
||||
- `docs`: For changes to the documentation
|
||||
- `examples`: For changes to the examples
|
||||
- `internal/lib`: For changes to Nhost's common libraries (internal)
|
||||
- `mintlify-openapi`: For changes to the Mintlify OpenAPI tool
|
||||
- `nhost-js`: For changes to the Nhost JavaScript SDK
|
||||
- `nixops`: For changes to the NixOps
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
|
||||
# Define valid types and packages
|
||||
VALID_TYPES="feat|fix|chore"
|
||||
VALID_PKGS="auth|ci|cli|codegen|dashboard|deps|docs|examples|mintlify-openapi|nhost-js|nixops|storage"
|
||||
VALID_PKGS="auth|ci|cli|codegen|dashboard|deps|docs|examples|internal\/lib|mintlify-openapi|nhost-js|nixops|storage"
|
||||
|
||||
# Check if title matches the pattern TYPE(PKG): SUMMARY
|
||||
if [[ ! "$PR_TITLE" =~ ^(${VALID_TYPES})\((${VALID_PKGS})\):\ .+ ]]; then
|
||||
|
||||
1
.github/workflows/auth_checks.yaml
vendored
1
.github/workflows/auth_checks.yaml
vendored
@@ -17,6 +17,7 @@ on:
|
||||
- '.golangci.yaml'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'internal/lib/**'
|
||||
- 'vendor/**'
|
||||
|
||||
# auth
|
||||
|
||||
2
.github/workflows/ci_update_changelog.yaml
vendored
2
.github/workflows/ci_update_changelog.yaml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
cd ${{ matrix.project }}
|
||||
TAG_NAME=$(make release-tag-name)
|
||||
VERSION=$(nix develop .\#cliff -c make changelog-next-version)
|
||||
if git tag | grep -q "$TAG_NAME@$VERSION"; then
|
||||
if git tag | grep -qx "$TAG_NAME@$VERSION"; then
|
||||
echo "Tag $TAG_NAME@$VERSION already exists, skipping release preparation"
|
||||
else
|
||||
echo "Tag $TAG_NAME@$VERSION does not exist, proceeding with release preparation"
|
||||
|
||||
2
.github/workflows/dashboard_wf_release.yaml
vendored
2
.github/workflows/dashboard_wf_release.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Bump version in source code
|
||||
run: |
|
||||
find cli -type f -exec sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' {} +
|
||||
sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' docs/reference/cli/commands.mdx
|
||||
sed -i 's/nhost\/dashboard:[^)]*/nhost\/dashboard:${{ inputs.VERSION }}/g' docs/reference/cli/commands.mdx
|
||||
|
||||
- name: "Create Pull Request"
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
|
||||
2
.github/workflows/gen_ai_review.yaml
vendored
2
.github/workflows/gen_ai_review.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.30
|
||||
uses: Codium-ai/pr-agent@v0.31
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
1
.github/workflows/storage_checks.yaml
vendored
1
.github/workflows/storage_checks.yaml
vendored
@@ -17,6 +17,7 @@ on:
|
||||
- '.golangci.yaml'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'internal/lib/**'
|
||||
- 'vendor/**'
|
||||
|
||||
# storage
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
## [cli@1.34.5] - 2025-11-06
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(nixops)* Bump go to 1.25.3 and nixpkgs due to CVEs (#3652)
|
||||
- *(cli)* Udpate certs and schema (#3675)
|
||||
- *(cli)* Bump nhost/dashboard to 2.41.0 (#3669)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -56,7 +56,7 @@ func CommandCloud() *cli.Command {
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDashboardVersion,
|
||||
Usage: "Dashboard version to use",
|
||||
Value: "nhost/dashboard:2.40.0",
|
||||
Value: "nhost/dashboard:2.41.0",
|
||||
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
|
||||
@@ -111,7 +111,7 @@ func CommandUp() *cli.Command { //nolint:funlen
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDashboardVersion,
|
||||
Usage: "Dashboard version to use",
|
||||
Value: "nhost/dashboard:2.40.0",
|
||||
Value: "nhost/dashboard:2.41.0",
|
||||
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
|
||||
@@ -196,10 +196,12 @@ config validate after making changes to your nhost.toml file to ensure it is val
|
||||
"mutations": map[string]any{
|
||||
"description": string("list of mutations to fetch"),
|
||||
"type": string("array"),
|
||||
"items": map[string]any{"type": string("string")},
|
||||
},
|
||||
"queries": map[string]any{
|
||||
"description": string("list of queries to fetch"),
|
||||
"type": string("array"),
|
||||
"items": map[string]any{"type": string("string")},
|
||||
},
|
||||
"summary": map[string]any{
|
||||
"default": bool(true),
|
||||
|
||||
@@ -53,6 +53,7 @@ func expectedAuth() *Service {
|
||||
"AUTH_PROVIDER_APPLE_ENABLED": "true",
|
||||
"AUTH_PROVIDER_APPLE_KEY_ID": "appleKeyId",
|
||||
"AUTH_PROVIDER_APPLE_PRIVATE_KEY": "applePrivateKey",
|
||||
"AUTH_PROVIDER_APPLE_SCOPE": "",
|
||||
"AUTH_PROVIDER_APPLE_TEAM_ID": "appleTeamId",
|
||||
"AUTH_PROVIDER_AZUREAD_CLIENT_ID": "azureadClientId",
|
||||
"AUTH_PROVIDER_AZUREAD_CLIENT_SECRET": "azureadClientSecret",
|
||||
@@ -75,9 +76,12 @@ func expectedAuth() *Service {
|
||||
"AUTH_PROVIDER_FACEBOOK_CLIENT_SECRET": "facebookClientSecret",
|
||||
"AUTH_PROVIDER_FACEBOOK_ENABLED": "true",
|
||||
"AUTH_PROVIDER_FACEBOOK_SCOPE": "email",
|
||||
"AUTH_PROVIDER_GITHUB_AUDIENCE": "audience",
|
||||
"AUTH_PROVIDER_GITHUB_CLIENT_ID": "githubClientId",
|
||||
"AUTH_PROVIDER_GITHUB_CLIENT_SECRET": "githubClientSecret",
|
||||
"AUTH_PROVIDER_GITHUB_ENABLED": "true",
|
||||
"AUTH_PROVIDER_GITHUB_SCOPE": "user:email",
|
||||
"AUTH_PROVIDER_GITLAB_AUDIENCE": "audience",
|
||||
"AUTH_PROVIDER_GITLAB_CLIENT_ID": "gitlabClientId",
|
||||
"AUTH_PROVIDER_GITLAB_CLIENT_SECRET": "gitlabClientSecret",
|
||||
"AUTH_PROVIDER_GITLAB_ENABLED": "true",
|
||||
@@ -97,6 +101,7 @@ func expectedAuth() *Service {
|
||||
"AUTH_PROVIDER_SPOTIFY_CLIENT_SECRET": "spotifyClientSecret",
|
||||
"AUTH_PROVIDER_SPOTIFY_ENABLED": "true",
|
||||
"AUTH_PROVIDER_SPOTIFY_SCOPE": "user-read-email",
|
||||
"AUTH_PROVIDER_STRAVA_AUDIENCE": "audience",
|
||||
"AUTH_PROVIDER_STRAVA_CLIENT_ID": "stravaClientId",
|
||||
"AUTH_PROVIDER_STRAVA_CLIENT_SECRET": "stravaClientSecret",
|
||||
"AUTH_PROVIDER_STRAVA_ENABLED": "true",
|
||||
|
||||
@@ -223,7 +223,7 @@ import (
|
||||
// Releases:
|
||||
//
|
||||
// https://github.com/nhost/hasura-storage/releases
|
||||
version: string | *"0.8.2"
|
||||
version: string | *"0.9.1"
|
||||
|
||||
// Networking (custom domains at the moment) are not allowed as we need to do further
|
||||
// configurations in the CDN. We will enable it again in the future.
|
||||
@@ -311,7 +311,7 @@ import (
|
||||
// Releases:
|
||||
//
|
||||
// https://github.com/nhost/hasura-auth/releases
|
||||
version: string | *"0.42.4"
|
||||
version: string | *"0.43.0"
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
|
||||
@@ -68,10 +68,12 @@ func (t *Tool) Register(mcpServer *server.MCPServer) {
|
||||
),
|
||||
mcp.WithArray(
|
||||
"queries",
|
||||
mcp.WithStringItems(),
|
||||
mcp.Description("list of queries to fetch"),
|
||||
),
|
||||
mcp.WithArray(
|
||||
"mutations",
|
||||
mcp.WithStringItems(),
|
||||
mcp.Description("list of mutations to fetch"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2247,6 +2247,14 @@ type AuthUserProvidersMinOrderBy struct {
|
||||
ProviderID *OrderBy `json:"providerId,omitempty"`
|
||||
}
|
||||
|
||||
// response of any mutation on the table "auth.user_providers"
|
||||
type AuthUserProvidersMutationResponse struct {
|
||||
// number of rows affected by the mutation
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
// data from the rows affected by the mutation
|
||||
Returning []*AuthUserProviders `json:"returning"`
|
||||
}
|
||||
|
||||
// Ordering options when selecting data from "auth.user_providers".
|
||||
type AuthUserProvidersOrderBy struct {
|
||||
ID *OrderBy `json:"id,omitempty"`
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIERDCCA8mgAwIBAgISBmRex3kpZ4Mz1/1kq05iqja/MAoGCCqGSM49BAMDMDIx
|
||||
MIIERTCCA8ugAwIBAgISBWD/E+b14mP5jv4DGWRVYv8fMAoGCCqGSM49BAMDMDIx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
|
||||
ODAeFw0yNTEwMDIxMDUxNDBaFw0yNTEyMzExMDUxMzlaMB8xHTAbBgNVBAMTFGxv
|
||||
Y2FsLmF1dGgubmhvc3QucnVuMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2cVM
|
||||
ojf8iXZGLneNfnke5LMJIxyTEeGbNOfCv4SOR4K/N4OkpvkUVbH2bRvX99uE9jaK
|
||||
515Y48PzPA/4+W1zTKOCAtAwggLMMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAU
|
||||
BggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUQqan
|
||||
raZoU5klAxsgkEVEMIkxmMQwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNy
|
||||
ODAeFw0yNTExMDYxMDUxMTBaFw0yNjAyMDQxMDUxMDlaMB8xHTAbBgNVBAMTFGxv
|
||||
Y2FsLmF1dGgubmhvc3QucnVuMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOah5
|
||||
ZLuUQp3pdMBxBWnT6E6/amW9LerKKEEdy3Nc8iAwG9LlnPH0z3m7a9wgEhpFEdlL
|
||||
Rr+qO+NhSRnv6+UF5KOCAtIwggLOMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAU
|
||||
BggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUGyb1
|
||||
TVK/0vf3uHO4x3R094aG2rEwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNy
|
||||
kcowMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTguaS5sZW5j
|
||||
ci5vcmcvMIHOBgNVHREEgcYwgcOCFGxvY2FsLmF1dGgubmhvc3QucnVughlsb2Nh
|
||||
bC5kYXNoYm9hcmQubmhvc3QucnVughJsb2NhbC5kYi5uaG9zdC5ydW6CGWxvY2Fs
|
||||
LmZ1bmN0aW9ucy5uaG9zdC5ydW6CF2xvY2FsLmdyYXBocWwubmhvc3QucnVughZs
|
||||
b2NhbC5oYXN1cmEubmhvc3QucnVughdsb2NhbC5tYWlsaG9nLm5ob3N0LnJ1boIX
|
||||
bG9jYWwuc3RvcmFnZS5uaG9zdC5ydW4wEwYDVR0gBAwwCjAIBgZngQwBAgEwLQYD
|
||||
VR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVuY3Iub3JnLzY0LmNybDCCAQIG
|
||||
CisGAQQB1nkCBAIEgfMEgfAA7gB1AO08S9boBsKkogBX28sk4jgB31Ev7cSGxXAP
|
||||
IN23Pj/gAAABmaTCI4YAAAQDAEYwRAIgXLRFL1EAXfvN6kd5m6udqlxfz4+5B6rq
|
||||
Cdhp/ZwDAZ8CIFYvalTkl5NEBEMD3vpPvrj8s1Yy2xsropEh/AvpavvLAHUAGYbU
|
||||
xyiqb/66A294Kk0BkarOLXIxD67OXXBBLSVMx9QAAAGZpMIjhwAABAMARjBEAiBk
|
||||
H1vqU9HNuBcf4UYL/xZ42BeUAARHStiFaIZtnR1kEgIgbIJ0CGqIpxmWuwCunl9p
|
||||
ar+rGLdQrCk9BZXq/VjPPAAwCgYIKoZIzj0EAwMDaQAwZgIxAKvk5a2zQsv7JLNj
|
||||
NO1ly+DI8qiy5nf4HQrOrHOjtmx5RUu0HSO9P0J0u069qAqXMgIxAMLdME9JUo2c
|
||||
TJo3pwWv5MRyg/MkOJ4ImKdDJXfIZNkEIUyP3vwTqImvZe07gJDsYg==
|
||||
VR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVuY3Iub3JnLzMyLmNybDCCAQQG
|
||||
CisGAQQB1nkCBAIEgfUEgfIA8AB2ABmG1Mcoqm/+ugNveCpNAZGqzi1yMQ+uzl1w
|
||||
QS0lTMfUAAABmlkAQokAAAQDAEcwRQIgWDtSxJfM2xcjvScVHOkn8bipzBhNhTnm
|
||||
B89TDh1/4XUCIQDe08W33PCx2D+akCdW9U9mZKQpIW6deLZSI3ZWpSNKMAB2AA5X
|
||||
lLzzrqk+MxssmQez95Dfm8I9cTIl3SGpJaxhxU4hAAABmlkAQn8AAAQDAEcwRQIg
|
||||
KnojmNTpNk1OFTQI0EnlPa2bpwqmUgmUCLeqE6SWfgoCIQCrhZbxYPHbGLF/HpRq
|
||||
vCTcOh24SRCuxlkqtaowbbfmKjAKBggqhkjOPQQDAwNoADBlAjEArstFIC+KAsfQ
|
||||
nLhtqsaNzkhftN5adDyr2CoE0WUPF1sLDi+xDnDO+JgIPL0YKAFNAjATJ4omhpc+
|
||||
I6/kWcef2RyO9YCGQQE9pdez5CYKb9o8YAntDSHM3b5nXXj3AX/USdQ=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgfJZOkvawA0vBMw9W
|
||||
ph8i1Z+SJQrFscPbqSYpxngzEDahRANCAATZxUyiN/yJdkYud41+eR7kswkjHJMR
|
||||
4Zs058K/hI5Hgr83g6Sm+RRVsfZtG9f324T2NornXljjw/M8D/j5bXNM
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgInXN4JRnXNTjx7rM
|
||||
avurZrN1EV1iebQeNUlMlFp7VJ+hRANCAAQ5qHlku5RCnel0wHEFadPoTr9qZb0t
|
||||
6sooQR3Lc1zyIDAb0uWc8fTPebtr3CASGkUR2UtGv6o742FJGe/r5QXk
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEWDCCA96gAwIBAgISBbvrSsjDQm4zevwwjxFGmeTMMAoGCCqGSM49BAMDMDIx
|
||||
MIIEVzCCA92gAwIBAgISBm54VdkoqD8s8efq7ceHaTihMAoGCCqGSM49BAMDMDIx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
|
||||
NzAeFw0yNTEwMDIxMDUyNTdaFw0yNTEyMzExMDUyNTZaMCExHzAdBgNVBAMMFiou
|
||||
YXV0aC5sb2NhbC5uaG9zdC5ydW4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATG
|
||||
x0o7t0pSrOoFc+pljtqJVxgaSW+w9D9C2WdysMeSKKOU+0MzaM4ynLUhETOpBs8E
|
||||
612mdcoeak+G1Emj6UVwo4IC4zCCAt8wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW
|
||||
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQ+
|
||||
lVsLiXSRLAECs9OgkCEBS7jMmzAfBgNVHSMEGDAWgBSuSJ7chx1EoG/aouVgdAR4
|
||||
wpwAgDAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNy5pLmxl
|
||||
ODAeFw0yNTExMDYxMDUyMjBaFw0yNjAyMDQxMDUyMTlaMCExHzAdBgNVBAMMFiou
|
||||
YXV0aC5sb2NhbC5uaG9zdC5ydW4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASI
|
||||
rTkZOM4ip42DCyDADXGc7oV3+OkimyTM3st2RIZWG28rFRwH0LebJV2cduq1Hdtl
|
||||
VxIEr+RhvyIL7gllueXUo4IC4jCCAt4wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW
|
||||
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTw
|
||||
bM86O381+aljU3oTUvwhZ90PCDAfBgNVHSMEGDAWgBSPDROi9i5+0VBsMxg4XVmO
|
||||
I3KRyjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lOC5pLmxl
|
||||
bmNyLm9yZy8wgd4GA1UdEQSB1jCB04IWKi5hdXRoLmxvY2FsLm5ob3N0LnJ1boIb
|
||||
Ki5kYXNoYm9hcmQubG9jYWwubmhvc3QucnVughQqLmRiLmxvY2FsLm5ob3N0LnJ1
|
||||
boIbKi5mdW5jdGlvbnMubG9jYWwubmhvc3QucnVughkqLmdyYXBocWwubG9jYWwu
|
||||
bmhvc3QucnVughgqLmhhc3VyYS5sb2NhbC5uaG9zdC5ydW6CGSoubWFpbGhvZy5s
|
||||
b2NhbC5uaG9zdC5ydW6CGSouc3RvcmFnZS5sb2NhbC5uaG9zdC5ydW4wEwYDVR0g
|
||||
BAwwCjAIBgZngQwBAgEwLQYDVR0fBCYwJDAioCCgHoYcaHR0cDovL2U3LmMubGVu
|
||||
Y3Iub3JnLzc3LmNybDCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB2AN3cyjSV1+EW
|
||||
BeeVMvrHn/g9HFDf2wA6FBJ2Ciysu8gqAAABmaTDUHkAAAQDAEcwRQIgWudJ8XKA
|
||||
BT5jq5Tl0xQLNb953pBi22Tb0TIWk+RSqHgCIQDsTrLVMFaQTV7EFCY1tFhi5qae
|
||||
SCpEwwdFcnom/nz6EAB3AO08S9boBsKkogBX28sk4jgB31Ev7cSGxXAPIN23Pj/g
|
||||
AAABmaTDWAsAAAQDAEgwRgIhALxIgIiutEwgNcGw7/cAdjFqUugct4HlZezIOLLP
|
||||
rg69AiEA8YCaK41rJDYztEKUIJEq2J2ktSqGYcl9gNKC+SiR4acwCgYIKoZIzj0E
|
||||
AwMDaAAwZQIwVG9yOiMRfKFFyFj1R8X/5U67QD84OhZ0oM0SZsVhezLedG5b8eFf
|
||||
/cWraREi8xbFAjEA/6RXweGzl08F7EtqBDoiqitScI2rbwGtP6s/evL0zXTABZD2
|
||||
ih7AGxjtg80IqIRe
|
||||
BAwwCjAIBgZngQwBAgEwLQYDVR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVu
|
||||
Y3Iub3JnLzM0LmNybDCCAQQGCisGAQQB1nkCBAIEgfUEgfIA8AB2AEmcm2neHXzs
|
||||
/DbezYdkprhbrwqHgBnRVVL76esp3fjDAAABmlkBVgkAAAQDAEcwRQIhANH6Ml3u
|
||||
IM4nAzwAIjIjBjn8EWbn1ZHfgwO+rlSo5rzpAiATPKE8Mx5LK1IayG5VCK1eCDyc
|
||||
rzt1HNbP9WSrpuHx+gB2ABmG1Mcoqm/+ugNveCpNAZGqzi1yMQ+uzl1wQS0lTMfU
|
||||
AAABmlkBVgcAAAQDAEcwRQIgIT/DhsIj9Aw7qf/2lknJCr907dEqC3/+QN3zlcOj
|
||||
iKoCIQCTguinYjJPZwU2dblaRQ2q7MTCMT2ZENExltxwYG3GzjAKBggqhkjOPQQD
|
||||
AwNoADBlAjEA5nFoNrLyeC079YpRvdah/HZIA/lUBh+LOo/NcEBD3aTGs2z8hU8z
|
||||
H4vMy3OnfQ9TAjBxigm7zE5/3CAcGoSOr/P0TL52nh+lO4SUVxcbKgYB8A2yo6o/
|
||||
kUkG7PiRB0uUpNw=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
|
||||
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
|
||||
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
|
||||
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
|
||||
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
|
||||
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
|
||||
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
|
||||
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
|
||||
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
|
||||
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
|
||||
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
|
||||
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
|
||||
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
|
||||
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
|
||||
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
|
||||
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
|
||||
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
|
||||
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
|
||||
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
|
||||
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
|
||||
+VUwFj9tmWxyR/M=
|
||||
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
|
||||
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
|
||||
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa
|
||||
Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF
|
||||
bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c
|
||||
S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb
|
||||
R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB
|
||||
9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB
|
||||
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j
|
||||
cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB
|
||||
BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE
|
||||
DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j
|
||||
ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0
|
||||
RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d
|
||||
AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8
|
||||
otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA
|
||||
aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm
|
||||
Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2
|
||||
HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1
|
||||
Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR
|
||||
xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d
|
||||
tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/
|
||||
jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS
|
||||
u1igv3OefnWjSQ==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrfNUSjLV/7j7LSBf
|
||||
zL/hvGEuv+uvf3/aimqjecO7vcShRANCAATGx0o7t0pSrOoFc+pljtqJVxgaSW+w
|
||||
9D9C2WdysMeSKKOU+0MzaM4ynLUhETOpBs8E612mdcoeak+G1Emj6UVw
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcrhROXQT85e+S8h8
|
||||
RE3Z7TPo3+WA2RmzJsXJbXkbi5qhRANCAASIrTkZOM4ip42DCyDADXGc7oV3+Oki
|
||||
myTM3st2RIZWG28rFRwH0LebJV2cduq1HdtlVxIEr+RhvyIL7gllueXU
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## [@nhost/dashboard@2.41.0] - 2025-11-04
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(auth)* Added endpoints to retrieve and refresh oauth2 providers' tokens (#3614)
|
||||
- *(dashboard)* Get github repositories from github itself (#3640)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(dashboard)* Update SQL editor to use correct hasura migrations API URL (#3645)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -15,7 +15,7 @@ function getCspHeader() {
|
||||
return [
|
||||
"default-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run",
|
||||
"script-src 'self' 'unsafe-eval' cdn.segment.com js.stripe.com challenges.cloudflare.com googletagmanager.com",
|
||||
"connect-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com",
|
||||
"connect-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com api.github.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' blob: data: github.com avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run",
|
||||
"font-src 'self' data:",
|
||||
@@ -126,4 +126,4 @@ module.exports = withBundleAnalyzer({
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export default function SocialProvidersSettings() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return nhost.auth.signInProviderURL('github', {
|
||||
connect: token,
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
redirectTo: `${window.location.origin}/account?signinProvider=github`,
|
||||
});
|
||||
}
|
||||
return '';
|
||||
|
||||
@@ -3,18 +3,21 @@ import {
|
||||
useGithubAuthentication,
|
||||
type UseGithubAuthenticationHookProps,
|
||||
} from '@/features/auth/AuthProviders/Github/hooks/useGithubAuthentication';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SiGithub } from '@icons-pack/react-simple-icons';
|
||||
|
||||
interface Props extends UseGithubAuthenticationHookProps {
|
||||
buttonText?: string;
|
||||
withAnonId?: boolean;
|
||||
redirectTo?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function GithubAuthButton({
|
||||
buttonText = 'Continue with GitHub',
|
||||
withAnonId = false,
|
||||
redirectTo,
|
||||
className,
|
||||
}: Props) {
|
||||
const { mutate: signInWithGithub, isLoading } = useGithubAuthentication({
|
||||
withAnonId,
|
||||
@@ -22,7 +25,10 @@ function GithubAuthButton({
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
className="gap-2 !bg-white text-sm+ !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
className={cn(
|
||||
'gap-2 !bg-white text-sm+ !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60',
|
||||
className,
|
||||
)}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
onClick={() => signInWithGithub()}
|
||||
|
||||
@@ -30,8 +30,8 @@ function useGithubAuthentication({
|
||||
};
|
||||
}
|
||||
|
||||
const redirectURl = nhost.auth.signInProviderURL('github', options);
|
||||
window.location.href = redirectURl;
|
||||
const redirectURL = nhost.auth.signInProviderURL('github', options);
|
||||
window.location.href = redirectURL;
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/component
|
||||
import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
|
||||
|
||||
function SignInWithGithub() {
|
||||
const redirectTo = useHostName();
|
||||
const redirectTo = `${useHostName()}?signinProvider=github`;
|
||||
return (
|
||||
<GithubAuthButton
|
||||
redirectTo={redirectTo}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/components/GithubAuthButton';
|
||||
import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
|
||||
|
||||
function SignUpWithGithub() {
|
||||
const redirectTo = `${useHostName()}?signinProvider=github`;
|
||||
return (
|
||||
<GithubAuthButton
|
||||
redirectTo={redirectTo}
|
||||
buttonText="Sign Up with GitHub"
|
||||
errorText="An error occurred while trying to sign up using GitHub. Please try again."
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorMessage } from '@/components/presentational/ErrorMessage';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
@@ -11,14 +12,33 @@ import { Link } from '@/components/ui/v2/Link';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/components/GithubAuthButton';
|
||||
import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
|
||||
import { EditRepositorySettings } from '@/features/orgs/projects/git/common/components/EditRepositorySettings';
|
||||
import { useGetGithubRepositoriesQuery } from '@/generated/graphql';
|
||||
import {
|
||||
getGitHubToken,
|
||||
saveGitHubToken,
|
||||
} from '@/features/orgs/projects/git/common/utils';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useGetAuthUserProvidersQuery } from '@/generated/graphql';
|
||||
import { useAccessToken } from '@/hooks/useAccessToken';
|
||||
import { GitHubAPIError, listGitHubInstallationRepos } from '@/lib/github';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import NavLink from 'next/link';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export type ConnectGitHubModalState = 'CONNECTING' | 'EDITING';
|
||||
export type ConnectGitHubModalState =
|
||||
| 'CONNECTING'
|
||||
| 'EDITING'
|
||||
| 'EXPIRED_GITHUB_SESSION'
|
||||
| 'GITHUB_CONNECTION_REQUIRED';
|
||||
|
||||
export interface ConnectGitHubModalProps {
|
||||
/**
|
||||
@@ -28,18 +48,153 @@ export interface ConnectGitHubModalProps {
|
||||
close?: VoidFunction;
|
||||
}
|
||||
|
||||
interface GitHubData {
|
||||
githubAppInstallations: Array<{
|
||||
id: number;
|
||||
accountLogin?: string;
|
||||
accountAvatarUrl?: string;
|
||||
}>;
|
||||
githubRepositories: Array<{
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
githubAppInstallation: {
|
||||
accountLogin?: string;
|
||||
accountAvatarUrl?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [ConnectGitHubModalState, setConnectGitHubModalState] =
|
||||
useState<ConnectGitHubModalState>('CONNECTING');
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
||||
const [githubData, setGithubData] = useState<GitHubData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
const { org, loading: loadingOrg } = useCurrentOrg();
|
||||
const hostname = useHostName();
|
||||
const token = useAccessToken();
|
||||
const {
|
||||
data,
|
||||
loading: loadingGithubConnected,
|
||||
error: errorGithubConnected,
|
||||
} = useGetAuthUserProvidersQuery();
|
||||
|
||||
const { data, loading, error, startPolling } =
|
||||
useGetGithubRepositoriesQuery();
|
||||
const githubProvider = data?.authUserProviders?.find(
|
||||
(item) => item.providerId === 'github',
|
||||
);
|
||||
|
||||
const getGitHubConnectUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return nhost.auth.signInProviderURL('github', {
|
||||
connect: token,
|
||||
redirectTo: `${window.location.origin}?signinProvider=github&state=signin-refresh:${org.slug}:${project?.subdomain}`,
|
||||
});
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(2000);
|
||||
}, [startPolling]);
|
||||
if (loadingGithubConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchGitHubData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (isEmptyValue(githubProvider)) {
|
||||
setConnectGitHubModalState('GITHUB_CONNECTION_REQUIRED');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const githubToken = getGitHubToken();
|
||||
|
||||
if (
|
||||
!githubToken?.authUserProviderId ||
|
||||
githubProvider!.id !== githubToken.authUserProviderId
|
||||
) {
|
||||
setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { refreshToken, expiresAt: expiresAtString } = githubToken;
|
||||
let accessToken = githubToken?.accessToken;
|
||||
|
||||
const expiresAt = new Date(expiresAtString).getTime();
|
||||
|
||||
const currentTime = Date.now();
|
||||
const expiresAtMargin = 60 * 1000;
|
||||
if (expiresAt - currentTime < expiresAtMargin) {
|
||||
if (!refreshToken) {
|
||||
setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshResponse = await nhost.auth.refreshProviderToken(
|
||||
'github',
|
||||
{ refreshToken },
|
||||
);
|
||||
|
||||
if (!refreshResponse.body) {
|
||||
setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
saveGitHubToken({
|
||||
...refreshResponse.body,
|
||||
authUserProviderId: githubProvider!.id,
|
||||
});
|
||||
|
||||
accessToken = refreshResponse.body.accessToken;
|
||||
}
|
||||
|
||||
const installations = await listGitHubInstallationRepos(accessToken);
|
||||
|
||||
const transformedData = {
|
||||
githubAppInstallations: installations.map((item) => ({
|
||||
id: item.installation.id,
|
||||
accountLogin: item.installation.account?.login,
|
||||
accountAvatarUrl: item.installation.account?.avatar_url,
|
||||
})),
|
||||
githubRepositories: installations.flatMap((item) =>
|
||||
item.repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
node_id: repo.node_id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
githubAppInstallation: {
|
||||
accountLogin: item.installation.account?.login,
|
||||
accountAvatarUrl: item.installation.account?.avatar_url,
|
||||
},
|
||||
})),
|
||||
),
|
||||
};
|
||||
|
||||
setGithubData(transformedData);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error fetching GitHub data:', err);
|
||||
if (err instanceof GitHubAPIError && err.status === 401) {
|
||||
setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(err?.message, getToastStyleProps());
|
||||
close?.();
|
||||
}
|
||||
};
|
||||
|
||||
fetchGitHubData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [githubProvider, loadingGithubConnected]);
|
||||
|
||||
const handleSelectAnotherRepository = () => {
|
||||
setSelectedRepoId(null);
|
||||
@@ -56,13 +211,91 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
if (errorGithubConnected instanceof Error) {
|
||||
return (
|
||||
<div className="px-1 md:w-[653px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto text-center">
|
||||
<div className="mx-auto h-8 w-8">
|
||||
<GitHubIcon className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text className="mt-2.5 text-center text-lg font-medium">
|
||||
Error fetching GitHub data
|
||||
</Text>
|
||||
<ErrorMessage>{errorGithubConnected.message}</ErrorMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (loading || loadingProject || loadingOrg || loadingGithubConnected) {
|
||||
return (
|
||||
<ActivityIndicator delay={500} label="Loading GitHub repositories..." />
|
||||
<div className="px-1 md:w-[653px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto text-center">
|
||||
<div className="mx-auto h-8 w-8">
|
||||
<GitHubIcon className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="mt-2.5 text-center text-lg font-medium">
|
||||
Loading repositories...
|
||||
</Text>
|
||||
<Text className="text-center text-xs font-normal" color="secondary">
|
||||
Fetching your GitHub repositories
|
||||
</Text>
|
||||
<div className="mb-2 mt-6 flex w-full">
|
||||
<Input placeholder="Search..." fullWidth disabled value="" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-import items-center justify-center border-y">
|
||||
<ActivityIndicator delay={0} label="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ConnectGitHubModalState === 'GITHUB_CONNECTION_REQUIRED') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-5 px-1 py-1 md:w-[653px]">
|
||||
<p className="text-center text-foreground">
|
||||
You need to connect your GitHub account to continue.
|
||||
</p>
|
||||
<NavLink
|
||||
href={getGitHubConnectUrl()}
|
||||
passHref
|
||||
rel="noreferrer noopener"
|
||||
legacyBehavior
|
||||
>
|
||||
<Button
|
||||
className="w-full max-w-72"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<GitHubIcon />}
|
||||
>
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ConnectGitHubModalState === 'EXPIRED_GITHUB_SESSION') {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-5 px-1 py-1 md:w-[653px]">
|
||||
<p className="text-center text-foreground">
|
||||
Please sign in with GitHub to continue.
|
||||
</p>
|
||||
<GithubAuthButton
|
||||
redirectTo={`${hostname}?signinProvider=github&state=signin-refresh:${org.slug}:${project!.subdomain}`}
|
||||
buttonText="Sign in with GitHub"
|
||||
className="w-full max-w-72 gap-2 !bg-primary !text-white disabled:!text-white disabled:!text-opacity-60 dark:!bg-white dark:!text-black dark:disabled:!text-black"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,25 +311,27 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const { githubAppInstallations } = data || {};
|
||||
const { githubAppInstallations } = githubData || {};
|
||||
|
||||
const filteredGitHubAppInstallations = data?.githubAppInstallations.filter(
|
||||
(githubApp) => !!githubApp.accountLogin,
|
||||
);
|
||||
const filteredGitHubAppInstallations =
|
||||
githubData?.githubAppInstallations.filter(
|
||||
(githubApp) => !!githubApp.accountLogin,
|
||||
);
|
||||
|
||||
const filteredGitHubRepositories = data?.githubRepositories.filter(
|
||||
const filteredGitHubRepositories = githubData?.githubRepositories.filter(
|
||||
(repo) => !!repo.githubAppInstallation,
|
||||
);
|
||||
|
||||
const filteredGitHubAppInstallationsNullValues =
|
||||
data?.githubAppInstallations.filter((githubApp) => !!githubApp.accountLogin)
|
||||
.length === 0;
|
||||
githubData?.githubAppInstallations.filter(
|
||||
(githubApp) => !!githubApp.accountLogin,
|
||||
).length === 0;
|
||||
|
||||
const faultyGitHubInstallation =
|
||||
githubAppInstallations?.length === 0 ||
|
||||
filteredGitHubAppInstallationsNullValues;
|
||||
|
||||
const noRepositoriesAdded = data?.githubRepositories.length === 0;
|
||||
const noRepositoriesAdded = githubData?.githubRepositories.length === 0;
|
||||
|
||||
if (faultyGitHubInstallation) {
|
||||
return (
|
||||
@@ -115,11 +350,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
href={process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}
|
||||
// Both `target` and `rel` are available when `href` is set. This is
|
||||
// a limitation of MUI.
|
||||
// @ts-ignore
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}?state=install-github-app:${org.slug}:${project!.subdomain}`}
|
||||
rel="noreferrer noopener"
|
||||
endIcon={<ArrowSquareOutIcon className="h-4 w-4" />}
|
||||
>
|
||||
@@ -179,8 +410,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
</List>
|
||||
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}?state=install-github-app:${org.slug}:${project!.subdomain}`}
|
||||
rel="noreferrer noopener"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
@@ -199,8 +429,8 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
className="text-center text-xs font-normal"
|
||||
color="secondary"
|
||||
>
|
||||
Showing repositories from {data?.githubAppInstallations.length}{' '}
|
||||
GitHub account(s)
|
||||
Showing repositories from{' '}
|
||||
{githubData?.githubAppInstallations.length} GitHub account(s)
|
||||
</Text>
|
||||
<div className="mb-2 mt-6 flex w-full">
|
||||
<Input
|
||||
@@ -226,7 +456,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => setSelectedRepoId(repo.id)}
|
||||
onClick={() => setSelectedRepoId(repo.node_id)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
@@ -268,8 +498,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
|
||||
Do you miss a repository, or do you need to connect another GitHub
|
||||
account?{' '}
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_GITHUB_APP_INSTALL_URL}?state=install-github-app:${org.slug}:${project!.subdomain}`}
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs font-medium"
|
||||
underline="hover"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
export interface EditRepositorySettingsProps {
|
||||
close?: () => void;
|
||||
openConnectGithubModal?: () => void;
|
||||
selectedRepoId?: string;
|
||||
selectedRepoId: string;
|
||||
connectGithubModalState?: ConnectGitHubModalState;
|
||||
handleSelectAnotherRepository?: () => void;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { EditRepositoryAndBranchSettings } from '@/features/orgs/projects/git/common/components/EditRepositoryAndBranchSettings';
|
||||
import type { EditRepositorySettingsFormData } from '@/features/orgs/projects/git/common/components/EditRepositorySettings';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUpdateApplicationMutation } from '@/generated/graphql';
|
||||
import { useConnectGithubRepoMutation } from '@/generated/graphql';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface EditRepositorySettingsModalProps {
|
||||
selectedRepoId?: string;
|
||||
selectedRepoId: string;
|
||||
close?: () => void;
|
||||
handleSelectAnotherRepository?: () => void;
|
||||
}
|
||||
@@ -33,45 +33,29 @@ export default function EditRepositorySettingsModal({
|
||||
|
||||
const { project, refetch: refetchProject } = useProject();
|
||||
|
||||
const [updateApp, { loading }] = useUpdateApplicationMutation();
|
||||
const [connectGithubRepo, { loading }] = useConnectGithubRepoMutation();
|
||||
|
||||
const handleEditGitHubIntegration = async (
|
||||
data: EditRepositorySettingsFormData,
|
||||
) => {
|
||||
try {
|
||||
if (!project?.githubRepository || selectedRepoId) {
|
||||
await updateApp({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
app: {
|
||||
githubRepositoryId: selectedRepoId,
|
||||
repositoryProductionBranch: data.productionBranch,
|
||||
nhostBaseFolder: data.repoBaseFolder,
|
||||
},
|
||||
},
|
||||
});
|
||||
await connectGithubRepo({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
githubNodeID: selectedRepoId,
|
||||
productionBranch: data.productionBranch,
|
||||
baseFolder: data.repoBaseFolder,
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedRepoId) {
|
||||
analytics.track('Project Connected to GitHub', {
|
||||
projectId: project?.id,
|
||||
projectName: project?.name,
|
||||
projectSubdomain: project?.subdomain,
|
||||
repositoryId: selectedRepoId,
|
||||
productionBranch: data.productionBranch,
|
||||
baseFolder: data.repoBaseFolder,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await updateApp({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
app: {
|
||||
repositoryProductionBranch: data.productionBranch,
|
||||
nhostBaseFolder: data.repoBaseFolder,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
analytics.track('Project Connected to GitHub', {
|
||||
projectId: project?.id,
|
||||
projectName: project?.name,
|
||||
projectSubdomain: project?.subdomain,
|
||||
repositoryId: selectedRepoId,
|
||||
productionBranch: data.productionBranch,
|
||||
baseFolder: data.repoBaseFolder,
|
||||
});
|
||||
|
||||
await refetchProject();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
mutation ConnectGithubRepo(
|
||||
$appID: uuid!
|
||||
$githubNodeID: String!
|
||||
$productionBranch: String!
|
||||
$baseFolder: String!
|
||||
) {
|
||||
connectGithubRepo(
|
||||
appID: $appID
|
||||
githubNodeID: $githubNodeID
|
||||
productionBranch: $productionBranch
|
||||
baseFolder: $baseFolder
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { ConnectGitHubModal } from '@/features/orgs/projects/git/common/components/ConnectGitHubModal';
|
||||
|
||||
export default function useGitHubModal() {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { openAlertDialog, closeAlertDialog } = useDialog();
|
||||
|
||||
function openGitHubModal() {
|
||||
openAlertDialog({
|
||||
title: 'Connect GitHub Repository',
|
||||
payload: <ConnectGitHubModal />,
|
||||
payload: <ConnectGitHubModal close={closeAlertDialog} />,
|
||||
props: {
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import type { ProviderSession } from '@nhost/nhost-js/auth';
|
||||
|
||||
const githubProviderTokenKey = 'nhost_provider_tokens_github';
|
||||
|
||||
export type GitHubProviderToken = ProviderSession & {
|
||||
authUserProviderId?: string;
|
||||
};
|
||||
|
||||
export function saveGitHubToken(token: GitHubProviderToken) {
|
||||
localStorage.setItem(githubProviderTokenKey, JSON.stringify(token));
|
||||
}
|
||||
|
||||
export function getGitHubToken() {
|
||||
const token = localStorage.getItem(githubProviderTokenKey);
|
||||
return isNotEmptyValue(token)
|
||||
? (JSON.parse(token) as GitHubProviderToken)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function clearGitHubToken() {
|
||||
localStorage.removeItem(githubProviderTokenKey);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './githubTokens';
|
||||
99
dashboard/src/lib/github.ts
Normal file
99
dashboard/src/lib/github.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Custom error class for GitHub API errors that preserves HTTP status codes
|
||||
*/
|
||||
export class GitHubAPIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GitHubAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
interface GitHubAppInstallation {
|
||||
id: number;
|
||||
account?: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all GitHub App installations accessible to the user
|
||||
* @param accessToken - The GitHub OAuth access token
|
||||
* @returns Array of app installations
|
||||
*/
|
||||
export async function listGitHubAppInstallations(accessToken: string): Promise<GitHubAppInstallation[]> {
|
||||
const response = await fetch('https://api.github.com/user/installations', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GitHubAPIError(
|
||||
`Failed to list installations: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.installations;
|
||||
}
|
||||
|
||||
interface GitHubRepo {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all repositories accessible through GitHub App installations
|
||||
* @param accessToken - The GitHub OAuth access token
|
||||
* @returns Array of repositories grouped by installation
|
||||
*/
|
||||
export async function listGitHubInstallationRepos(accessToken: string) {
|
||||
const installations = await listGitHubAppInstallations(accessToken);
|
||||
|
||||
const reposByInstallation = await Promise.all(
|
||||
installations.map(async (installation) => {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/user/installations/${installation.id}/repositories`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
cache: 'no-cache',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GitHubAPIError(
|
||||
`Failed to list repos for installation ${installation.id}: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
installation,
|
||||
repositories: data.repositories as GitHubRepo[],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return reposByInstallation;
|
||||
}
|
||||
@@ -77,12 +77,12 @@ function MyApp({
|
||||
|
||||
<CacheProvider value={emotionCache}>
|
||||
<NhostProvider nhost={nhost}>
|
||||
<AuthProvider>
|
||||
<NhostApolloProvider
|
||||
fetchPolicy="cache-and-network"
|
||||
nhost={nhost}
|
||||
connectToDevTools={process.env.NEXT_PUBLIC_ENV === 'dev'}
|
||||
>
|
||||
<NhostApolloProvider
|
||||
fetchPolicy="cache-and-network"
|
||||
nhost={nhost}
|
||||
connectToDevTools={process.env.NEXT_PUBLIC_ENV === 'dev'}
|
||||
>
|
||||
<AuthProvider>
|
||||
<UIProvider>
|
||||
<Toaster position="bottom-center" />
|
||||
<ThemeProvider
|
||||
@@ -106,8 +106,8 @@ function MyApp({
|
||||
</RetryableErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</UIProvider>
|
||||
</NhostApolloProvider>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</NhostApolloProvider>
|
||||
</NhostProvider>
|
||||
</CacheProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function authProtected<P extends JSX.IntrinsicAttributes>(
|
||||
Comp: ComponentType<P>,
|
||||
) {
|
||||
return function AuthProtected(props: P) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/signin');
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return <Comp {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const [state, setState] = useState({
|
||||
error: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { installation_id: installationId } = router.query;
|
||||
|
||||
useEffect(() => {
|
||||
async function installGithubApp() {
|
||||
try {
|
||||
await nhost.functions.fetch('/client/github-app-installation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
installationId,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
error,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
window.close();
|
||||
}
|
||||
|
||||
// run in async manner
|
||||
installGithubApp();
|
||||
}, [installationId]);
|
||||
|
||||
if (state.loading) {
|
||||
return <ActivityIndicator delay={500} label="Loading..." />;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw state.error;
|
||||
}
|
||||
|
||||
return <div>GitHub connection completed. You can close this tab.</div>;
|
||||
}
|
||||
|
||||
export default authProtected(Page);
|
||||
@@ -1,12 +1,38 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { OrgLayout } from '@/features/orgs/layout/OrgLayout';
|
||||
import { SettingsLayout } from '@/features/orgs/layout/SettingsLayout';
|
||||
import { useGitHubModal } from '@/features/orgs/projects/git/common/hooks/useGitHubModal';
|
||||
import { BaseDirectorySettings } from '@/features/orgs/projects/git/settings/components/BaseDirectorySettings';
|
||||
import { DeploymentBranchSettings } from '@/features/orgs/projects/git/settings/components/DeploymentBranchSettings';
|
||||
import { GitConnectionSettings } from '@/features/orgs/projects/git/settings/components/GitConnectionSettings';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, type ReactElement } from 'react';
|
||||
|
||||
export default function GitSettingsPage() {
|
||||
const router = useRouter();
|
||||
const { pathname, replace, isReady: isRouterReady } = router;
|
||||
const { 'github-modal': githubModal, ...remainingQuery } = router.query;
|
||||
const { openGitHubModal } = useGitHubModal();
|
||||
|
||||
const removeQueryParamsFromURL = useCallback(() => {
|
||||
replace({ pathname, query: remainingQuery }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}, [replace, remainingQuery, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouterReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof githubModal === 'string') {
|
||||
removeQueryParamsFromURL();
|
||||
|
||||
openGitHubModal();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [githubModal, isRouterReady]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import {
|
||||
clearGitHubToken,
|
||||
saveGitHubToken,
|
||||
type GitHubProviderToken,
|
||||
} from '@/features/orgs/projects/git/common/utils';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost/';
|
||||
import { useGetAuthUserProvidersLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { type Session } from '@nhost/nhost-js/auth';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { AuthContext, type AuthContextType } from './AuthContext';
|
||||
|
||||
function AuthProvider({ children }: PropsWithChildren) {
|
||||
const nhost = useNhostClient();
|
||||
const [getAuthUserProviders] = useGetAuthUserProvidersLazyQuery();
|
||||
const {
|
||||
query,
|
||||
isReady: isRouterReady,
|
||||
@@ -21,7 +29,15 @@ function AuthProvider({ children }: PropsWithChildren) {
|
||||
pathname,
|
||||
push,
|
||||
} = useRouter();
|
||||
const { refreshToken, error, errorDescription, ...remainingQuery } = query;
|
||||
const {
|
||||
refreshToken,
|
||||
error,
|
||||
errorDescription,
|
||||
signinProvider,
|
||||
state,
|
||||
provider_state: providerState,
|
||||
...remainingQuery
|
||||
} = query;
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
@@ -55,7 +71,6 @@ function AuthProvider({ children }: PropsWithChildren) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
// reset state if we have just signed out
|
||||
setIsSigningOut(false);
|
||||
if (refreshToken && typeof refreshToken === 'string') {
|
||||
const sessionResponse = await nhost.auth.refreshToken({
|
||||
@@ -63,24 +78,84 @@ function AuthProvider({ children }: PropsWithChildren) {
|
||||
});
|
||||
setSession(sessionResponse.body);
|
||||
removeQueryParamsFromURL();
|
||||
|
||||
if (sessionResponse.body && signinProvider === 'github') {
|
||||
try {
|
||||
const providerTokensResponse =
|
||||
await nhost.auth.getProviderTokens(signinProvider);
|
||||
if (providerTokensResponse.body) {
|
||||
const { data } = await getAuthUserProviders();
|
||||
const githubProvider = data?.authUserProviders?.find(
|
||||
(provider) => provider.providerId === 'github',
|
||||
);
|
||||
const newGitHubToken: GitHubProviderToken =
|
||||
providerTokensResponse.body;
|
||||
if (isNotEmptyValue(githubProvider?.id)) {
|
||||
newGitHubToken.authUserProviderId = githubProvider!.id;
|
||||
}
|
||||
saveGitHubToken(newGitHubToken);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch provider tokens:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const currentSession = nhost.getUserSession();
|
||||
setSession(currentSession);
|
||||
}
|
||||
|
||||
// handle OAuth redirect errors (e.g., error=unverified-user)
|
||||
if (
|
||||
state &&
|
||||
typeof state === 'string' &&
|
||||
state.startsWith('signin-refresh:')
|
||||
) {
|
||||
const [, orgSlug, projectSubdomain] = state.split(':');
|
||||
removeQueryParamsFromURL();
|
||||
await push(
|
||||
`/orgs/${orgSlug}/projects/${projectSubdomain}/settings/git?github-modal`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
if (error === 'unverified-user') {
|
||||
removeQueryParamsFromURL();
|
||||
await push('/email/verify');
|
||||
} else {
|
||||
const description =
|
||||
typeof errorDescription === 'string'
|
||||
? errorDescription
|
||||
: 'An error occurred during the sign-in process. Please try again.';
|
||||
toast.error(description, getToastStyleProps());
|
||||
removeQueryParamsFromURL();
|
||||
await push('/signin');
|
||||
switch (error) {
|
||||
case 'unverified-user': {
|
||||
removeQueryParamsFromURL();
|
||||
await push('/email/verify');
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
* If the state isn't handled by Hasura auth, it returns `invalid-state`.
|
||||
* However, we check the provider_state search param to see if it has this format:
|
||||
* `install-github-app:<org-slug>:<project-subdomain>`.
|
||||
* If it has this format, that means that we connected to GitHub in `/settings/git`,
|
||||
* thus we need to show the connect GitHub modal again.
|
||||
* Otherwise, we fall through to default error handling.
|
||||
*/
|
||||
case 'invalid-state': {
|
||||
if (
|
||||
isNotEmptyValue(providerState) &&
|
||||
typeof providerState === 'string' &&
|
||||
providerState.startsWith('install-github-app:')
|
||||
) {
|
||||
const [, orgSlug, projectSubdomain] = providerState.split(':');
|
||||
removeQueryParamsFromURL();
|
||||
await push(
|
||||
`/orgs/${orgSlug}/projects/${projectSubdomain}/settings/git?github-modal`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Fall through to default error handling if state search param is invalid
|
||||
}
|
||||
default: {
|
||||
const description =
|
||||
typeof errorDescription === 'string'
|
||||
? errorDescription
|
||||
: 'An error occurred during the sign-in process. Please try again.';
|
||||
toast.error(description, getToastStyleProps());
|
||||
removeQueryParamsFromURL();
|
||||
await push('/signin');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +178,7 @@ function AuthProvider({ children }: PropsWithChildren) {
|
||||
nhost.auth.signOut({
|
||||
refreshToken: session!.refreshToken,
|
||||
});
|
||||
clearGitHubToken();
|
||||
|
||||
await push('/signin');
|
||||
},
|
||||
|
||||
@@ -108,16 +108,16 @@ function Providers({ children }: PropsWithChildren<{}>) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CacheProvider value={emotionCache}>
|
||||
<NhostProvider nhost={nhost}>
|
||||
<AuthProvider>
|
||||
<ApolloProvider client={mockClient}>
|
||||
<ApolloProvider client={mockClient}>
|
||||
<AuthProvider>
|
||||
<UIProvider>
|
||||
<Toaster position="bottom-center" />
|
||||
<ThemeProvider theme={theme}>
|
||||
<DialogProvider>{children}</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</UIProvider>
|
||||
</ApolloProvider>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</ApolloProvider>
|
||||
</NhostProvider>
|
||||
</CacheProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
82
dashboard/src/utils/__generated__/graphql.ts
generated
82
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -3118,7 +3118,9 @@ export type ConfigSystemConfigPostgres = {
|
||||
database: Scalars['String'];
|
||||
disk?: Maybe<ConfigSystemConfigPostgresDisk>;
|
||||
enabled?: Maybe<Scalars['Boolean']>;
|
||||
encryptColumnKey?: Maybe<Scalars['String']>;
|
||||
majorVersion?: Maybe<Scalars['String']>;
|
||||
oldEncryptColumnKey?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigSystemConfigPostgresComparisonExp = {
|
||||
@@ -3129,7 +3131,9 @@ export type ConfigSystemConfigPostgresComparisonExp = {
|
||||
database?: InputMaybe<ConfigStringComparisonExp>;
|
||||
disk?: InputMaybe<ConfigSystemConfigPostgresDiskComparisonExp>;
|
||||
enabled?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
encryptColumnKey?: InputMaybe<ConfigStringComparisonExp>;
|
||||
majorVersion?: InputMaybe<ConfigStringComparisonExp>;
|
||||
oldEncryptColumnKey?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigSystemConfigPostgresConnectionString = {
|
||||
@@ -3193,7 +3197,9 @@ export type ConfigSystemConfigPostgresInsertInput = {
|
||||
database: Scalars['String'];
|
||||
disk?: InputMaybe<ConfigSystemConfigPostgresDiskInsertInput>;
|
||||
enabled?: InputMaybe<Scalars['Boolean']>;
|
||||
encryptColumnKey?: InputMaybe<Scalars['String']>;
|
||||
majorVersion?: InputMaybe<Scalars['String']>;
|
||||
oldEncryptColumnKey?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigSystemConfigPostgresUpdateInput = {
|
||||
@@ -3201,7 +3207,9 @@ export type ConfigSystemConfigPostgresUpdateInput = {
|
||||
database?: InputMaybe<Scalars['String']>;
|
||||
disk?: InputMaybe<ConfigSystemConfigPostgresDiskUpdateInput>;
|
||||
enabled?: InputMaybe<Scalars['Boolean']>;
|
||||
encryptColumnKey?: InputMaybe<Scalars['String']>;
|
||||
majorVersion?: InputMaybe<Scalars['String']>;
|
||||
oldEncryptColumnKey?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigSystemConfigUpdateInput = {
|
||||
@@ -4426,6 +4434,7 @@ export type Apps = {
|
||||
billingDedicatedCompute?: Maybe<Billing_Dedicated_Compute>;
|
||||
/** An object relationship */
|
||||
billingSubscriptions?: Maybe<Billing_Subscriptions>;
|
||||
/** main entrypoint to the configuration */
|
||||
config?: Maybe<ConfigConfig>;
|
||||
createdAt: Scalars['timestamptz'];
|
||||
/** An object relationship */
|
||||
@@ -11094,6 +11103,7 @@ export type Deployments = {
|
||||
commitSHA: Scalars['String'];
|
||||
commitUserAvatarUrl?: Maybe<Scalars['String']>;
|
||||
commitUserName?: Maybe<Scalars['String']>;
|
||||
createdAt: Scalars['timestamptz'];
|
||||
deploymentEndedAt?: Maybe<Scalars['timestamptz']>;
|
||||
/** An array relationship */
|
||||
deploymentLogs: Array<DeploymentLogs>;
|
||||
@@ -11191,6 +11201,7 @@ export type Deployments_Bool_Exp = {
|
||||
commitSHA?: InputMaybe<String_Comparison_Exp>;
|
||||
commitUserAvatarUrl?: InputMaybe<String_Comparison_Exp>;
|
||||
commitUserName?: InputMaybe<String_Comparison_Exp>;
|
||||
createdAt?: InputMaybe<Timestamptz_Comparison_Exp>;
|
||||
deploymentEndedAt?: InputMaybe<Timestamptz_Comparison_Exp>;
|
||||
deploymentLogs?: InputMaybe<DeploymentLogs_Bool_Exp>;
|
||||
deploymentLogs_aggregate?: InputMaybe<DeploymentLogs_Aggregate_Bool_Exp>;
|
||||
@@ -11222,6 +11233,7 @@ export type Deployments_Insert_Input = {
|
||||
commitSHA?: InputMaybe<Scalars['String']>;
|
||||
commitUserAvatarUrl?: InputMaybe<Scalars['String']>;
|
||||
commitUserName?: InputMaybe<Scalars['String']>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentEndedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentLogs?: InputMaybe<DeploymentLogs_Arr_Rel_Insert_Input>;
|
||||
deploymentStartedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
@@ -11246,6 +11258,7 @@ export type Deployments_Max_Fields = {
|
||||
commitSHA?: Maybe<Scalars['String']>;
|
||||
commitUserAvatarUrl?: Maybe<Scalars['String']>;
|
||||
commitUserName?: Maybe<Scalars['String']>;
|
||||
createdAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentEndedAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentStartedAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentStatus?: Maybe<Scalars['String']>;
|
||||
@@ -11268,6 +11281,7 @@ export type Deployments_Max_Order_By = {
|
||||
commitSHA?: InputMaybe<Order_By>;
|
||||
commitUserAvatarUrl?: InputMaybe<Order_By>;
|
||||
commitUserName?: InputMaybe<Order_By>;
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
deploymentEndedAt?: InputMaybe<Order_By>;
|
||||
deploymentStartedAt?: InputMaybe<Order_By>;
|
||||
deploymentStatus?: InputMaybe<Order_By>;
|
||||
@@ -11291,6 +11305,7 @@ export type Deployments_Min_Fields = {
|
||||
commitSHA?: Maybe<Scalars['String']>;
|
||||
commitUserAvatarUrl?: Maybe<Scalars['String']>;
|
||||
commitUserName?: Maybe<Scalars['String']>;
|
||||
createdAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentEndedAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentStartedAt?: Maybe<Scalars['timestamptz']>;
|
||||
deploymentStatus?: Maybe<Scalars['String']>;
|
||||
@@ -11313,6 +11328,7 @@ export type Deployments_Min_Order_By = {
|
||||
commitSHA?: InputMaybe<Order_By>;
|
||||
commitUserAvatarUrl?: InputMaybe<Order_By>;
|
||||
commitUserName?: InputMaybe<Order_By>;
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
deploymentEndedAt?: InputMaybe<Order_By>;
|
||||
deploymentStartedAt?: InputMaybe<Order_By>;
|
||||
deploymentStatus?: InputMaybe<Order_By>;
|
||||
@@ -11359,6 +11375,7 @@ export type Deployments_Order_By = {
|
||||
commitSHA?: InputMaybe<Order_By>;
|
||||
commitUserAvatarUrl?: InputMaybe<Order_By>;
|
||||
commitUserName?: InputMaybe<Order_By>;
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
deploymentEndedAt?: InputMaybe<Order_By>;
|
||||
deploymentLogs_aggregate?: InputMaybe<DeploymentLogs_Aggregate_Order_By>;
|
||||
deploymentStartedAt?: InputMaybe<Order_By>;
|
||||
@@ -11393,6 +11410,8 @@ export enum Deployments_Select_Column {
|
||||
/** column name */
|
||||
CommitUserName = 'commitUserName',
|
||||
/** column name */
|
||||
CreatedAt = 'createdAt',
|
||||
/** column name */
|
||||
DeploymentEndedAt = 'deploymentEndedAt',
|
||||
/** column name */
|
||||
DeploymentStartedAt = 'deploymentStartedAt',
|
||||
@@ -11427,6 +11446,7 @@ export type Deployments_Set_Input = {
|
||||
commitSHA?: InputMaybe<Scalars['String']>;
|
||||
commitUserAvatarUrl?: InputMaybe<Scalars['String']>;
|
||||
commitUserName?: InputMaybe<Scalars['String']>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentEndedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentStartedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentStatus?: InputMaybe<Scalars['String']>;
|
||||
@@ -11457,6 +11477,7 @@ export type Deployments_Stream_Cursor_Value_Input = {
|
||||
commitSHA?: InputMaybe<Scalars['String']>;
|
||||
commitUserAvatarUrl?: InputMaybe<Scalars['String']>;
|
||||
commitUserName?: InputMaybe<Scalars['String']>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentEndedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentStartedAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
deploymentStatus?: InputMaybe<Scalars['String']>;
|
||||
@@ -11485,6 +11506,8 @@ export enum Deployments_Update_Column {
|
||||
/** column name */
|
||||
CommitUserName = 'commitUserName',
|
||||
/** column name */
|
||||
CreatedAt = 'createdAt',
|
||||
/** column name */
|
||||
DeploymentEndedAt = 'deploymentEndedAt',
|
||||
/** column name */
|
||||
DeploymentStartedAt = 'deploymentStartedAt',
|
||||
@@ -13067,6 +13090,7 @@ export type Mutation_Root = {
|
||||
billingUpgradeFreeOrganization: Scalars['String'];
|
||||
billingUploadReports: Scalars['Boolean'];
|
||||
changeDatabaseVersion: Scalars['Boolean'];
|
||||
connectGithubRepo: Scalars['Boolean'];
|
||||
/** delete single row from the table: "announcements_read" */
|
||||
deleteAnnouncementRead?: Maybe<Announcements_Read>;
|
||||
/** delete data from the table: "announcements_read" */
|
||||
@@ -13968,6 +13992,15 @@ export type Mutation_RootChangeDatabaseVersionArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootConnectGithubRepoArgs = {
|
||||
appID: Scalars['uuid'];
|
||||
baseFolder: Scalars['String'];
|
||||
githubNodeID: Scalars['String'];
|
||||
productionBranch: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootDeleteAnnouncementReadArgs = {
|
||||
id: Scalars['uuid'];
|
||||
@@ -27517,6 +27550,16 @@ export type ResetDatabasePasswordMutationVariables = Exact<{
|
||||
|
||||
export type ResetDatabasePasswordMutation = { __typename?: 'mutation_root', resetPostgresPassword: boolean };
|
||||
|
||||
export type ConnectGithubRepoMutationVariables = Exact<{
|
||||
appID: Scalars['uuid'];
|
||||
githubNodeID: Scalars['String'];
|
||||
productionBranch: Scalars['String'];
|
||||
baseFolder: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ConnectGithubRepoMutation = { __typename?: 'mutation_root', connectGithubRepo: boolean };
|
||||
|
||||
export type GetHasuraSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -29446,6 +29489,45 @@ export function useResetDatabasePasswordMutation(baseOptions?: Apollo.MutationHo
|
||||
export type ResetDatabasePasswordMutationHookResult = ReturnType<typeof useResetDatabasePasswordMutation>;
|
||||
export type ResetDatabasePasswordMutationResult = Apollo.MutationResult<ResetDatabasePasswordMutation>;
|
||||
export type ResetDatabasePasswordMutationOptions = Apollo.BaseMutationOptions<ResetDatabasePasswordMutation, ResetDatabasePasswordMutationVariables>;
|
||||
export const ConnectGithubRepoDocument = gql`
|
||||
mutation ConnectGithubRepo($appID: uuid!, $githubNodeID: String!, $productionBranch: String!, $baseFolder: String!) {
|
||||
connectGithubRepo(
|
||||
appID: $appID
|
||||
githubNodeID: $githubNodeID
|
||||
productionBranch: $productionBranch
|
||||
baseFolder: $baseFolder
|
||||
)
|
||||
}
|
||||
`;
|
||||
export type ConnectGithubRepoMutationFn = Apollo.MutationFunction<ConnectGithubRepoMutation, ConnectGithubRepoMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useConnectGithubRepoMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useConnectGithubRepoMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useConnectGithubRepoMutation` 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 [connectGithubRepoMutation, { data, loading, error }] = useConnectGithubRepoMutation({
|
||||
* variables: {
|
||||
* appID: // value for 'appID'
|
||||
* githubNodeID: // value for 'githubNodeID'
|
||||
* productionBranch: // value for 'productionBranch'
|
||||
* baseFolder: // value for 'baseFolder'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useConnectGithubRepoMutation(baseOptions?: Apollo.MutationHookOptions<ConnectGithubRepoMutation, ConnectGithubRepoMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<ConnectGithubRepoMutation, ConnectGithubRepoMutationVariables>(ConnectGithubRepoDocument, options);
|
||||
}
|
||||
export type ConnectGithubRepoMutationHookResult = ReturnType<typeof useConnectGithubRepoMutation>;
|
||||
export type ConnectGithubRepoMutationResult = Apollo.MutationResult<ConnectGithubRepoMutation>;
|
||||
export type ConnectGithubRepoMutationOptions = Apollo.BaseMutationOptions<ConnectGithubRepoMutation, ConnectGithubRepoMutationVariables>;
|
||||
export const GetHasuraSettingsDocument = gql`
|
||||
query GetHasuraSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
|
||||
@@ -1665,6 +1665,7 @@ components:
|
||||
- oauth-provider-error
|
||||
- invalid-otp
|
||||
- cannot-send-sms
|
||||
- provider-account-already-linked
|
||||
required:
|
||||
- status
|
||||
- message
|
||||
|
||||
@@ -254,7 +254,7 @@ Start local development environment
|
||||
|
||||
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
|
||||
|
||||
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.40.0)
|
||||
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.41.0)
|
||||
|
||||
**--disable-tls**: Disable TLS
|
||||
|
||||
@@ -284,7 +284,7 @@ Start local development environment connected to an Nhost Cloud project (BETA)
|
||||
|
||||
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
|
||||
|
||||
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.40.0)
|
||||
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.41.0)
|
||||
|
||||
**--disable-tls**: Disable TLS
|
||||
|
||||
|
||||
@@ -3203,6 +3203,7 @@ type ErrorResponseError =
|
||||
| 'oauth-provider-error'
|
||||
| 'invalid-otp'
|
||||
| 'cannot-send-sms'
|
||||
| 'provider-account-already-linked'
|
||||
```
|
||||
|
||||
Error code identifying the specific application error
|
||||
|
||||
@@ -206,8 +206,7 @@ paths:
|
||||
Last-Modified:
|
||||
description: "Date and time the file was last modified"
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
$ref: '#/components/schemas/RFC2822Date'
|
||||
Surrogate-Key:
|
||||
description: "Cache key for surrogate caching"
|
||||
schema:
|
||||
@@ -248,8 +247,7 @@ paths:
|
||||
Last-Modified:
|
||||
description: "Date and time the file was last modified"
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
$ref: '#/components/schemas/RFC2822Date'
|
||||
Surrogate-Key:
|
||||
description: "Cache key for surrogate caching"
|
||||
schema:
|
||||
@@ -391,8 +389,7 @@ paths:
|
||||
Last-Modified:
|
||||
description: "Date and time the file was last modified"
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
$ref: '#/components/schemas/RFC2822Date'
|
||||
Accept-Ranges:
|
||||
description: "Always set to bytes. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges"
|
||||
schema:
|
||||
@@ -675,8 +672,7 @@ paths:
|
||||
Last-Modified:
|
||||
description: "Date and time the file was last modified"
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
$ref: '#/components/schemas/RFC2822Date'
|
||||
Surrogate-Key:
|
||||
description: "Cache key for surrogate caching"
|
||||
schema:
|
||||
@@ -717,8 +713,7 @@ paths:
|
||||
Last-Modified:
|
||||
description: "Date and time the file was last modified"
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
$ref: '#/components/schemas/RFC2822Date'
|
||||
Surrogate-Key:
|
||||
description: "Cache key for surrogate caching"
|
||||
schema:
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
gofumpt
|
||||
golangci-lint
|
||||
gqlgenc
|
||||
oapi-codegen
|
||||
|
||||
# internal packages
|
||||
self.packages.${system}.codegen
|
||||
|
||||
6
go.mod
6
go.mod
@@ -16,7 +16,6 @@ require (
|
||||
github.com/davidbyttow/govips/v2 v2.16.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/gin-contrib/cors v1.7.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/go-webauthn/webauthn v0.12.2
|
||||
@@ -29,8 +28,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/lmittmann/tint v1.0.7
|
||||
github.com/mark3labs/mcp-go v0.41.1
|
||||
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2
|
||||
github.com/nhost/be v0.0.0-20251106114258-352de15d30f5
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/pb33f/libopenapi v0.21.12
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
@@ -156,7 +154,7 @@ require (
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/quic-go/quic-go v0.54.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -162,8 +162,6 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -338,11 +336,9 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48 h1:+Oh4Rbr1psWlBaQTakoBYFNB8jBioiXuimNMaNPLTHk=
|
||||
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
|
||||
github.com/nhost/be v0.0.0-20251106114258-352de15d30f5 h1:D1n4dI9LBk6W61/RIQClauPailPHXIp2V7okg6KQMlk=
|
||||
github.com/nhost/be v0.0.0-20251106114258-352de15d30f5/go.mod h1:5aMnG2R3UQWFLlb3BcA0btBleWIn1ez3pSwg37YthuA=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2 h1:/H99UzvHQAUxXK8pzdcGAZgjCVeXdFDAUUWaJT0k0eI=
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2/go.mod h1:2HJDQjH8jzK2/k/VKcWl+/T41H7ai2bKa6dN3AA2GpA=
|
||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
@@ -385,8 +381,8 @@ github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
|
||||
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
|
||||
13
internal/lib/oapi/errors.go
Normal file
13
internal/lib/oapi/errors.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package oapi
|
||||
|
||||
import "fmt"
|
||||
|
||||
type AuthenticatorError struct {
|
||||
Scheme string
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthenticatorError) Error() string {
|
||||
return fmt.Sprintf("security error [%s]: %s", e.Code, e.Message)
|
||||
}
|
||||
10
internal/lib/oapi/example/api/api.go
Normal file
10
internal/lib/oapi/example/api/api.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:generate oapi-codegen -config server.cfg.yaml openapi.yaml
|
||||
//go:generate oapi-codegen -config types.cfg.yaml openapi.yaml
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed openapi.yaml
|
||||
var OpenAPISchema []byte
|
||||
200
internal/lib/oapi/example/api/openapi.yaml
Normal file
200
internal/lib/oapi/example/api/openapi.yaml
Normal file
@@ -0,0 +1,200 @@
|
||||
openapi: "3.0.0"
|
||||
|
||||
paths:
|
||||
/signin/email-password:
|
||||
post:
|
||||
summary: Sign in with email and password
|
||||
description: Authenticate a user with their email and password. Returns a session object or MFA challenge if two-factor authentication is enabled.
|
||||
operationId: signInEmailPassword
|
||||
requestBody:
|
||||
description: User credentials for email and password authentication
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SignInEmailPasswordRequest"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SignInEmailPasswordResponse"
|
||||
description: "Authentication successful. If MFA is enabled, a challenge will be returned instead of a session."
|
||||
default:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
description: "An error occurred while processing the request"
|
||||
|
||||
/user/email/change:
|
||||
post:
|
||||
summary: Change user email
|
||||
description: Request to change the authenticated user's email address. A verification email will be sent to the new address to confirm the change. Requires elevated permissions.
|
||||
operationId: changeUserEmail
|
||||
tags:
|
||||
- user
|
||||
security:
|
||||
- BearerAuthElevated: []
|
||||
requestBody:
|
||||
description: New email address and optional redirect URL for email change
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserEmailChangeRequest"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: >-
|
||||
Email change requested. An email with a verification link has been sent to the new address
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OKResponse"
|
||||
default:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
description: "An error occurred while processing the request"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: "Bearer authentication with JWT access token. Used to authenticate requests to protected endpoints."
|
||||
BearerAuthElevated:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: "Bearer authentication that requires elevated permissions. Used for sensitive operations that may require additional security measures such as recent authentication. For details see https://docs.nhost.io/products/auth/elevated-permissions"
|
||||
|
||||
schemas:
|
||||
SignInEmailPasswordRequest:
|
||||
type: object
|
||||
description: "Request to authenticate using email and password"
|
||||
additionalProperties: false
|
||||
properties:
|
||||
email:
|
||||
description: "User's email address"
|
||||
example: "john.smith@nhost.io"
|
||||
format: email
|
||||
type: string
|
||||
password:
|
||||
description: "User's password"
|
||||
example: "Str0ngPassw#ord-94|%"
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
|
||||
SignInEmailPasswordResponse:
|
||||
type: object
|
||||
description: "Response for email-password authentication that may include a session or MFA challenge"
|
||||
additionalProperties: false
|
||||
properties:
|
||||
session:
|
||||
$ref: "#/components/schemas/Session"
|
||||
|
||||
Session:
|
||||
type: object
|
||||
description: "User authentication session containing tokens and user information"
|
||||
additionalProperties: false
|
||||
properties:
|
||||
accessToken:
|
||||
type: string
|
||||
description: "JWT token for authenticating API requests"
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
accessTokenExpiresIn:
|
||||
type: integer
|
||||
format: int64
|
||||
description: "Expiration time of the access token in seconds"
|
||||
example: 900
|
||||
refreshTokenId:
|
||||
description: "Identifier for the refresh token"
|
||||
example: "2c35b6f3-c4b9-48e3-978a-d4d0f1d42e24"
|
||||
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
|
||||
type: string
|
||||
refreshToken:
|
||||
description: "Token used to refresh the access token"
|
||||
example: "2c35b6f3-c4b9-48e3-978a-d4d0f1d42e24"
|
||||
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
|
||||
type: string
|
||||
required:
|
||||
- accessToken
|
||||
- accessTokenExpiresIn
|
||||
- refreshToken
|
||||
- refreshTokenId
|
||||
|
||||
UserEmailChangeRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
newEmail:
|
||||
description: A valid email
|
||||
example: john.smith@nhost.io
|
||||
format: email
|
||||
type: string
|
||||
required:
|
||||
- newEmail
|
||||
|
||||
OKResponse:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
enum:
|
||||
- OK
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
description: "Standardized error response"
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
description: "HTTP status error code"
|
||||
type: integer
|
||||
example: 400
|
||||
message:
|
||||
description: "Human-friendly error message"
|
||||
type: string
|
||||
example: "Invalid email format"
|
||||
error:
|
||||
description: "Error code identifying the specific application error"
|
||||
type: string
|
||||
enum:
|
||||
- default-role-must-be-in-allowed-roles
|
||||
- disabled-endpoint
|
||||
- disabled-user
|
||||
- email-already-in-use
|
||||
- email-already-verified
|
||||
- forbidden-anonymous
|
||||
- internal-server-error
|
||||
- invalid-email-password
|
||||
- invalid-request
|
||||
- locale-not-allowed
|
||||
- password-too-short
|
||||
- password-in-hibp-database
|
||||
- redirectTo-not-allowed
|
||||
- role-not-allowed
|
||||
- signup-disabled
|
||||
- unverified-user
|
||||
- user-not-anonymous
|
||||
- invalid-pat
|
||||
- invalid-refresh-token
|
||||
- invalid-ticket
|
||||
- disabled-mfa-totp
|
||||
- no-totp-secret
|
||||
- invalid-totp
|
||||
- mfa-type-not-found
|
||||
- totp-already-active
|
||||
- invalid-state
|
||||
- oauth-token-echange-failed
|
||||
- oauth-profile-fetch-failed
|
||||
- oauth-provider-error
|
||||
- invalid-otp
|
||||
- cannot-send-sms
|
||||
required:
|
||||
- status
|
||||
- message
|
||||
- error
|
||||
6
internal/lib/oapi/example/api/server.cfg.yaml
Normal file
6
internal/lib/oapi/example/api/server.cfg.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
package: api
|
||||
generate:
|
||||
gin-server: true
|
||||
embedded-spec: true
|
||||
strict-server: true
|
||||
output: server.gen.go
|
||||
351
internal/lib/oapi/example/api/server.gen.go
Normal file
351
internal/lib/oapi/example/api/server.gen.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// Package api provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/gin-gonic/gin"
|
||||
strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin"
|
||||
)
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
// Sign in with email and password
|
||||
// (POST /signin/email-password)
|
||||
SignInEmailPassword(c *gin.Context)
|
||||
// Change user email
|
||||
// (POST /user/email/change)
|
||||
ChangeUserEmail(c *gin.Context)
|
||||
}
|
||||
|
||||
// ServerInterfaceWrapper converts contexts to parameters.
|
||||
type ServerInterfaceWrapper struct {
|
||||
Handler ServerInterface
|
||||
HandlerMiddlewares []MiddlewareFunc
|
||||
ErrorHandler func(*gin.Context, error, int)
|
||||
}
|
||||
|
||||
type MiddlewareFunc func(c *gin.Context)
|
||||
|
||||
// SignInEmailPassword operation middleware
|
||||
func (siw *ServerInterfaceWrapper) SignInEmailPassword(c *gin.Context) {
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
middleware(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
siw.Handler.SignInEmailPassword(c)
|
||||
}
|
||||
|
||||
// ChangeUserEmail operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ChangeUserEmail(c *gin.Context) {
|
||||
|
||||
c.Set(BearerAuthElevatedScopes, []string{})
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
middleware(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
siw.Handler.ChangeUserEmail(c)
|
||||
}
|
||||
|
||||
// GinServerOptions provides options for the Gin server.
|
||||
type GinServerOptions struct {
|
||||
BaseURL string
|
||||
Middlewares []MiddlewareFunc
|
||||
ErrorHandler func(*gin.Context, error, int)
|
||||
}
|
||||
|
||||
// RegisterHandlers creates http.Handler with routing matching OpenAPI spec.
|
||||
func RegisterHandlers(router gin.IRouter, si ServerInterface) {
|
||||
RegisterHandlersWithOptions(router, si, GinServerOptions{})
|
||||
}
|
||||
|
||||
// RegisterHandlersWithOptions creates http.Handler with additional options
|
||||
func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) {
|
||||
errorHandler := options.ErrorHandler
|
||||
if errorHandler == nil {
|
||||
errorHandler = func(c *gin.Context, err error, statusCode int) {
|
||||
c.JSON(statusCode, gin.H{"msg": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
wrapper := ServerInterfaceWrapper{
|
||||
Handler: si,
|
||||
HandlerMiddlewares: options.Middlewares,
|
||||
ErrorHandler: errorHandler,
|
||||
}
|
||||
|
||||
router.POST(options.BaseURL+"/signin/email-password", wrapper.SignInEmailPassword)
|
||||
router.POST(options.BaseURL+"/user/email/change", wrapper.ChangeUserEmail)
|
||||
}
|
||||
|
||||
type SignInEmailPasswordRequestObject struct {
|
||||
Body *SignInEmailPasswordJSONRequestBody
|
||||
}
|
||||
|
||||
type SignInEmailPasswordResponseObject interface {
|
||||
VisitSignInEmailPasswordResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type SignInEmailPassword200JSONResponse SignInEmailPasswordResponse
|
||||
|
||||
func (response SignInEmailPassword200JSONResponse) VisitSignInEmailPasswordResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type SignInEmailPassworddefaultJSONResponse struct {
|
||||
Body ErrorResponse
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response SignInEmailPassworddefaultJSONResponse) VisitSignInEmailPasswordResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type ChangeUserEmailRequestObject struct {
|
||||
Body *ChangeUserEmailJSONRequestBody
|
||||
}
|
||||
|
||||
type ChangeUserEmailResponseObject interface {
|
||||
VisitChangeUserEmailResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type ChangeUserEmail200JSONResponse OKResponse
|
||||
|
||||
func (response ChangeUserEmail200JSONResponse) VisitChangeUserEmailResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type ChangeUserEmaildefaultJSONResponse struct {
|
||||
Body ErrorResponse
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response ChangeUserEmaildefaultJSONResponse) VisitChangeUserEmailResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
// StrictServerInterface represents all server handlers.
|
||||
type StrictServerInterface interface {
|
||||
// Sign in with email and password
|
||||
// (POST /signin/email-password)
|
||||
SignInEmailPassword(ctx context.Context, request SignInEmailPasswordRequestObject) (SignInEmailPasswordResponseObject, error)
|
||||
// Change user email
|
||||
// (POST /user/email/change)
|
||||
ChangeUserEmail(ctx context.Context, request ChangeUserEmailRequestObject) (ChangeUserEmailResponseObject, error)
|
||||
}
|
||||
|
||||
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
|
||||
type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc
|
||||
|
||||
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
|
||||
return &strictHandler{ssi: ssi, middlewares: middlewares}
|
||||
}
|
||||
|
||||
type strictHandler struct {
|
||||
ssi StrictServerInterface
|
||||
middlewares []StrictMiddlewareFunc
|
||||
}
|
||||
|
||||
// SignInEmailPassword operation middleware
|
||||
func (sh *strictHandler) SignInEmailPassword(ctx *gin.Context) {
|
||||
var request SignInEmailPasswordRequestObject
|
||||
|
||||
var body SignInEmailPasswordJSONRequestBody
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.SignInEmailPassword(ctx, request.(SignInEmailPasswordRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "SignInEmailPassword")
|
||||
}
|
||||
|
||||
response, err := handler(ctx, request)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else if validResponse, ok := response.(SignInEmailPasswordResponseObject); ok {
|
||||
if err := validResponse.VisitSignInEmailPasswordResponse(ctx.Writer); err != nil {
|
||||
ctx.Error(err)
|
||||
}
|
||||
} else if response != nil {
|
||||
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeUserEmail operation middleware
|
||||
func (sh *strictHandler) ChangeUserEmail(ctx *gin.Context) {
|
||||
var request ChangeUserEmailRequestObject
|
||||
|
||||
var body ChangeUserEmailJSONRequestBody
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.ChangeUserEmail(ctx, request.(ChangeUserEmailRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "ChangeUserEmail")
|
||||
}
|
||||
|
||||
response, err := handler(ctx, request)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else if validResponse, ok := response.(ChangeUserEmailResponseObject); ok {
|
||||
if err := validResponse.VisitChangeUserEmailResponse(ctx.Writer); err != nil {
|
||||
ctx.Error(err)
|
||||
}
|
||||
} else if response != nil {
|
||||
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"brfbfwUAAP//3ciGL/8UAAA=",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
// or error if failed to decode
|
||||
func decodeSpec() ([]byte, error) {
|
||||
zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(zipped))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing spec: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(zr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing spec: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var rawSpec = decodeSpecCached()
|
||||
|
||||
// a naive cached of a decoded swagger spec
|
||||
func decodeSpecCached() func() ([]byte, error) {
|
||||
data, err := decodeSpec()
|
||||
return func() ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
|
||||
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
|
||||
res := make(map[string]func() ([]byte, error))
|
||||
if len(pathToFile) > 0 {
|
||||
res[pathToFile] = rawSpec
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSwagger returns the Swagger specification corresponding to the generated code
|
||||
// in this file. The external references of Swagger specification are resolved.
|
||||
// The logic of resolving external references is tightly connected to "import-mapping" feature.
|
||||
// Externally referenced files must be embedded in the corresponding golang packages.
|
||||
// Urls can be supported but this task was out of the scope.
|
||||
func GetSwagger() (swagger *openapi3.T, err error) {
|
||||
resolvePath := PathToRawSpec("")
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
loader.IsExternalRefsAllowed = true
|
||||
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
|
||||
pathToFile := url.String()
|
||||
pathToFile = path.Clean(pathToFile)
|
||||
getSpec, ok := resolvePath[pathToFile]
|
||||
if !ok {
|
||||
err1 := fmt.Errorf("path not found: %s", pathToFile)
|
||||
return nil, err1
|
||||
}
|
||||
return getSpec()
|
||||
}
|
||||
var specData []byte
|
||||
specData, err = rawSpec()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
swagger, err = loader.LoadFromData(specData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
4
internal/lib/oapi/example/api/types.cfg.yaml
Normal file
4
internal/lib/oapi/example/api/types.cfg.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
package: api
|
||||
generate:
|
||||
models: true
|
||||
output: types.gen.go
|
||||
112
internal/lib/oapi/example/api/types.gen.go
Normal file
112
internal/lib/oapi/example/api/types.gen.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package api provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
const (
|
||||
BearerAuthElevatedScopes = "BearerAuthElevated.Scopes"
|
||||
)
|
||||
|
||||
// Defines values for ErrorResponseError.
|
||||
const (
|
||||
CannotSendSms ErrorResponseError = "cannot-send-sms"
|
||||
DefaultRoleMustBeInAllowedRoles ErrorResponseError = "default-role-must-be-in-allowed-roles"
|
||||
DisabledEndpoint ErrorResponseError = "disabled-endpoint"
|
||||
DisabledMfaTotp ErrorResponseError = "disabled-mfa-totp"
|
||||
DisabledUser ErrorResponseError = "disabled-user"
|
||||
EmailAlreadyInUse ErrorResponseError = "email-already-in-use"
|
||||
EmailAlreadyVerified ErrorResponseError = "email-already-verified"
|
||||
ForbiddenAnonymous ErrorResponseError = "forbidden-anonymous"
|
||||
InternalServerError ErrorResponseError = "internal-server-error"
|
||||
InvalidEmailPassword ErrorResponseError = "invalid-email-password"
|
||||
InvalidOtp ErrorResponseError = "invalid-otp"
|
||||
InvalidPat ErrorResponseError = "invalid-pat"
|
||||
InvalidRefreshToken ErrorResponseError = "invalid-refresh-token"
|
||||
InvalidRequest ErrorResponseError = "invalid-request"
|
||||
InvalidState ErrorResponseError = "invalid-state"
|
||||
InvalidTicket ErrorResponseError = "invalid-ticket"
|
||||
InvalidTotp ErrorResponseError = "invalid-totp"
|
||||
LocaleNotAllowed ErrorResponseError = "locale-not-allowed"
|
||||
MfaTypeNotFound ErrorResponseError = "mfa-type-not-found"
|
||||
NoTotpSecret ErrorResponseError = "no-totp-secret"
|
||||
OauthProfileFetchFailed ErrorResponseError = "oauth-profile-fetch-failed"
|
||||
OauthProviderError ErrorResponseError = "oauth-provider-error"
|
||||
OauthTokenEchangeFailed ErrorResponseError = "oauth-token-echange-failed"
|
||||
PasswordInHibpDatabase ErrorResponseError = "password-in-hibp-database"
|
||||
PasswordTooShort ErrorResponseError = "password-too-short"
|
||||
RedirectToNotAllowed ErrorResponseError = "redirectTo-not-allowed"
|
||||
RoleNotAllowed ErrorResponseError = "role-not-allowed"
|
||||
SignupDisabled ErrorResponseError = "signup-disabled"
|
||||
TotpAlreadyActive ErrorResponseError = "totp-already-active"
|
||||
UnverifiedUser ErrorResponseError = "unverified-user"
|
||||
UserNotAnonymous ErrorResponseError = "user-not-anonymous"
|
||||
)
|
||||
|
||||
// Defines values for OKResponse.
|
||||
const (
|
||||
OK OKResponse = "OK"
|
||||
)
|
||||
|
||||
// ErrorResponse Standardized error response
|
||||
type ErrorResponse struct {
|
||||
// Error Error code identifying the specific application error
|
||||
Error ErrorResponseError `json:"error"`
|
||||
|
||||
// Message Human-friendly error message
|
||||
Message string `json:"message"`
|
||||
|
||||
// Status HTTP status error code
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// ErrorResponseError Error code identifying the specific application error
|
||||
type ErrorResponseError string
|
||||
|
||||
// OKResponse defines model for OKResponse.
|
||||
type OKResponse string
|
||||
|
||||
// Session User authentication session containing tokens and user information
|
||||
type Session struct {
|
||||
// AccessToken JWT token for authenticating API requests
|
||||
AccessToken string `json:"accessToken"`
|
||||
|
||||
// AccessTokenExpiresIn Expiration time of the access token in seconds
|
||||
AccessTokenExpiresIn int64 `json:"accessTokenExpiresIn"`
|
||||
|
||||
// RefreshToken Token used to refresh the access token
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
|
||||
// RefreshTokenId Identifier for the refresh token
|
||||
RefreshTokenId string `json:"refreshTokenId"`
|
||||
}
|
||||
|
||||
// SignInEmailPasswordRequest Request to authenticate using email and password
|
||||
type SignInEmailPasswordRequest struct {
|
||||
// Email User's email address
|
||||
Email openapi_types.Email `json:"email"`
|
||||
|
||||
// Password User's password
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// SignInEmailPasswordResponse Response for email-password authentication that may include a session or MFA challenge
|
||||
type SignInEmailPasswordResponse struct {
|
||||
// Session User authentication session containing tokens and user information
|
||||
Session *Session `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// UserEmailChangeRequest defines model for UserEmailChangeRequest.
|
||||
type UserEmailChangeRequest struct {
|
||||
// NewEmail A valid email
|
||||
NewEmail openapi_types.Email `json:"newEmail"`
|
||||
}
|
||||
|
||||
// SignInEmailPasswordJSONRequestBody defines body for SignInEmailPassword for application/json ContentType.
|
||||
type SignInEmailPasswordJSONRequestBody = SignInEmailPasswordRequest
|
||||
|
||||
// ChangeUserEmailJSONRequestBody defines body for ChangeUserEmail for application/json ContentType.
|
||||
type ChangeUserEmailJSONRequestBody = UserEmailChangeRequest
|
||||
49
internal/lib/oapi/example/controller/controller.go
Normal file
49
internal/lib/oapi/example/controller/controller.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/nhost/nhost/internal/lib/oapi/example/api"
|
||||
)
|
||||
|
||||
type Controller struct{}
|
||||
|
||||
func NewController() *Controller {
|
||||
return &Controller{}
|
||||
}
|
||||
|
||||
func (c *Controller) SignInEmailPassword( //nolint:ireturn
|
||||
_ context.Context, req api.SignInEmailPasswordRequestObject,
|
||||
) (api.SignInEmailPasswordResponseObject, error) {
|
||||
switch req.Body.Email {
|
||||
case "bad@email.com":
|
||||
return api.SignInEmailPassworddefaultJSONResponse{
|
||||
Body: api.ErrorResponse{
|
||||
Error: api.DisabledUser,
|
||||
Message: "The user account is disabled.",
|
||||
Status: http.StatusConflict,
|
||||
},
|
||||
StatusCode: http.StatusConflict,
|
||||
}, nil
|
||||
case "crash@email.com":
|
||||
return nil, errors.New("simulated server crash") //nolint:err113
|
||||
}
|
||||
|
||||
return api.SignInEmailPassword200JSONResponse{
|
||||
Session: &api.Session{
|
||||
AccessToken: "access_token_example",
|
||||
AccessTokenExpiresIn: 900, //nolint:mnd
|
||||
RefreshToken: "refresh_token_example",
|
||||
RefreshTokenId: "refresh_token_id_example",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) ChangeUserEmail( //nolint:ireturn
|
||||
_ context.Context,
|
||||
_ api.ChangeUserEmailRequestObject,
|
||||
) (api.ChangeUserEmailResponseObject, error) {
|
||||
return api.ChangeUserEmail200JSONResponse(api.OK), nil
|
||||
}
|
||||
109
internal/lib/oapi/example/main.go
Normal file
109
internal/lib/oapi/example/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lmittmann/tint"
|
||||
"github.com/nhost/nhost/internal/lib/oapi"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/example/api"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/example/controller"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
)
|
||||
|
||||
const apiPrefix = "/"
|
||||
|
||||
func getLogger() *slog.Logger {
|
||||
handler := tint.NewHandler(os.Stdout, &tint.Options{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
TimeFormat: time.StampMilli,
|
||||
NoColor: false,
|
||||
ReplaceAttr: nil,
|
||||
})
|
||||
|
||||
return slog.New(handler)
|
||||
}
|
||||
|
||||
func authFn(
|
||||
ctx context.Context,
|
||||
input *openapi3filter.AuthenticationInput,
|
||||
) error {
|
||||
_, ok := ctx.Value(oapi.GinContextKey).(*gin.Context)
|
||||
if !ok {
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: "unable to get context",
|
||||
}
|
||||
}
|
||||
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: "your access token is invalid",
|
||||
}
|
||||
}
|
||||
|
||||
func setupRouter(logger *slog.Logger) (*gin.Engine, error) {
|
||||
ctrl := controller.NewController()
|
||||
handler := api.NewStrictHandler(ctrl, []api.StrictMiddlewareFunc{})
|
||||
|
||||
router, mw, err := oapi.NewRouter(
|
||||
api.OpenAPISchema,
|
||||
apiPrefix,
|
||||
authFn,
|
||||
middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create oapi router: %w", err)
|
||||
}
|
||||
|
||||
api.RegisterHandlersWithOptions(
|
||||
router,
|
||||
handler,
|
||||
api.GinServerOptions{
|
||||
BaseURL: apiPrefix,
|
||||
Middlewares: []api.MiddlewareFunc{mw},
|
||||
ErrorHandler: nil,
|
||||
},
|
||||
)
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
logger := getLogger()
|
||||
|
||||
router, err := setupRouter(logger) //nolint:contextcheck
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{ //nolint:exhaustruct
|
||||
Addr: ":8080",
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 5 * time.Second, //nolint:mnd
|
||||
}
|
||||
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.ErrorContext(ctx, "server failed", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
177
internal/lib/oapi/example/main_test.go
Normal file
177
internal/lib/oapi/example/main_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func makeRequest(
|
||||
router *gin.Engine,
|
||||
method, path string,
|
||||
headers map[string]string,
|
||||
body io.Reader,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := getLogger()
|
||||
|
||||
router, err := setupRouter(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set up router: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
headers map[string]string
|
||||
body io.Reader
|
||||
expectedStatus int
|
||||
expectedResponse string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(`{"email": "asd@asd.com", "password": "p4ssw0rd"}`),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedResponse: "{\"session\":{\"accessToken\":\"access_token_example\",\"accessTokenExpiresIn\":900,\"refreshToken\":\"refresh_token_example\",\"refreshTokenId\":\"refresh_token_id_example\"}}\n", //nolint:lll
|
||||
},
|
||||
|
||||
{
|
||||
name: "expected error",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(
|
||||
`{"email": "bad@email.com", "password": "p4ssw0rd"}`,
|
||||
),
|
||||
expectedStatus: http.StatusConflict,
|
||||
expectedResponse: "{\"error\":\"disabled-user\",\"message\":\"The user account is disabled.\",\"status\":409}\n", //nolint:lll
|
||||
},
|
||||
|
||||
{
|
||||
name: "unexpected error",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(
|
||||
`{"email": "crash@email.com", "password": "p4ssw0rd"}`,
|
||||
),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedResponse: `{"errors":"internal-server-error","message":"simulated server crash"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing body",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: nil,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedResponse: `{"error":"request-validation-error","reason":"value is required but missing"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "wrong param",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(
|
||||
`{"wrong":"asd", "email": "asd@asd.com", "password": "p4ssw0rd"}`,
|
||||
),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedResponse: `{"error":"schema-validation-error","reason":"property \"wrong\" is unsupported"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing param",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(`{"email": "asd@asd.com"}`),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedResponse: `{"error":"schema-validation-error","reason":"property \"password\" is missing"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "invalid param",
|
||||
method: http.MethodPost,
|
||||
path: "/signin/email-password",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(`{"email": "asdasd.com", "password": "p4ssw0rd"}`),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedResponse: `{"errors":"bad-request","message":"email: failed to pass regex validation"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "needs security",
|
||||
method: http.MethodPost,
|
||||
path: "/user/email/change",
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: strings.NewReader(`{"newEmail": "new@asd.com"`),
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedResponse: `{"error":"unauthorized","reason":"your access token is invalid","securityScheme":"BearerAuthElevated"}`, //nolint:lll
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := makeRequest(router, tc.method, tc.path, tc.headers, tc.body)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != tc.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tc.expectedStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(body), tc.expectedResponse); diff != "" {
|
||||
t.Errorf("Response body mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
140
internal/lib/oapi/middleware/cors.go
Normal file
140
internal/lib/oapi/middleware/cors.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORSOptions configures the CORS middleware behavior.
|
||||
//
|
||||
// The middleware supports three strategies for handling Access-Control-Allow-Headers:
|
||||
// - nil (default): Reflects the Access-Control-Request-Headers from the client
|
||||
// - empty slice: Denies all headers (no Access-Control-Allow-Headers header is set)
|
||||
// - non-empty slice: Uses the specified headers
|
||||
type CORSOptions struct {
|
||||
// AllowedOrigins is a list of origins permitted to make cross-origin requests.
|
||||
// Use "*" or nil slice to allow all origins.
|
||||
AllowedOrigins []string
|
||||
|
||||
// AllowedMethods is a list of HTTP methods the client is permitted to use.
|
||||
// Common values: GET, POST, PUT, DELETE, PATCH, OPTIONS.
|
||||
AllowedMethods []string
|
||||
|
||||
// AllowedHeaders controls which headers clients can use in requests.
|
||||
// - nil: reflects client's Access-Control-Request-Headers (permissive)
|
||||
// - empty slice: denies all headers
|
||||
// - non-empty: allows only specified headers
|
||||
AllowedHeaders []string
|
||||
|
||||
// ExposedHeaders lists headers that browsers are allowed to access.
|
||||
// By default, browsers only expose simple response headers.
|
||||
ExposedHeaders []string
|
||||
|
||||
// AllowCredentials indicates whether the request can include credentials
|
||||
// (cookies, authorization headers, or TLS client certificates).
|
||||
AllowCredentials bool
|
||||
|
||||
// MaxAge indicates how long (in seconds) the results of a preflight request
|
||||
// can be cached. Empty string means no caching directive is sent.
|
||||
MaxAge string
|
||||
}
|
||||
|
||||
// CORS returns a Gin middleware handler that implements Cross-Origin Resource Sharing (CORS).
|
||||
//
|
||||
// The middleware handles both preflight (OPTIONS) requests and actual requests, setting
|
||||
// appropriate CORS headers based on the provided configuration. It automatically adds
|
||||
// the "Vary: Origin, Access-Control-Request-Method" header for proper cache behavior.
|
||||
//
|
||||
// For preflight requests (OPTIONS), the middleware responds with 204 No Content and
|
||||
// prevents further request processing. For actual requests, it sets CORS headers and
|
||||
// continues the middleware chain.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// router.Use(middleware.CORS(middleware.CORSOptions{
|
||||
// AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
|
||||
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
// AllowedHeaders: nil, // reflects client headers
|
||||
// AllowCredentials: true,
|
||||
// MaxAge: "3600",
|
||||
// }))
|
||||
func CORS(opts CORSOptions) gin.HandlerFunc { //nolint:cyclop,funlen
|
||||
allowedMethods := strings.Join(opts.AllowedMethods, ", ")
|
||||
exposedHeaders := strings.Join(opts.ExposedHeaders, ", ")
|
||||
|
||||
allowCredentials := "false"
|
||||
if opts.AllowCredentials {
|
||||
allowCredentials = "true"
|
||||
}
|
||||
|
||||
var (
|
||||
headerStrategy string // "reflect", "specific", or "deny"
|
||||
allowedHeaders string
|
||||
)
|
||||
switch {
|
||||
case opts.AllowedHeaders == nil:
|
||||
headerStrategy = "reflect"
|
||||
case len(opts.AllowedHeaders) == 0:
|
||||
headerStrategy = "deny"
|
||||
default:
|
||||
headerStrategy = "specific"
|
||||
allowedHeaders = strings.Join(opts.AllowedHeaders, ", ")
|
||||
}
|
||||
|
||||
f := func(c *gin.Context, origin string) {
|
||||
if opts.AllowedOrigins != nil &&
|
||||
!slices.Contains(opts.AllowedOrigins, origin) &&
|
||||
!slices.Contains(opts.AllowedOrigins, "*") {
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", allowedMethods)
|
||||
|
||||
// Handle allowed headers based on strategy
|
||||
switch headerStrategy {
|
||||
case "specific":
|
||||
c.Header("Access-Control-Allow-Headers", allowedHeaders)
|
||||
case "reflect":
|
||||
headers := c.Request.Header.Get("Access-Control-Request-Headers")
|
||||
if headers != "" {
|
||||
c.Header("Access-Control-Allow-Headers", headers)
|
||||
}
|
||||
case "deny":
|
||||
// Don't set the header at all
|
||||
}
|
||||
|
||||
if exposedHeaders != "" {
|
||||
c.Header("Access-Control-Expose-Headers", exposedHeaders)
|
||||
}
|
||||
|
||||
c.Header("Access-Control-Allow-Credentials", allowCredentials)
|
||||
|
||||
if opts.MaxAge != "" {
|
||||
c.Header("Access-Control-Max-Age", opts.MaxAge)
|
||||
}
|
||||
|
||||
c.Writer.Header().Add("Vary", "Origin, Access-Control-Request-Method")
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
f(c, origin)
|
||||
|
||||
c.Header("Content-Length", "0")
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if origin != "" {
|
||||
f(c, origin)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
323
internal/lib/oapi/middleware/cors_test.go
Normal file
323
internal/lib/oapi/middleware/cors_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
)
|
||||
|
||||
func TestCORS(t *testing.T) { //nolint:maintidx
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts middleware.CORSOptions
|
||||
requestMethod string
|
||||
requestOrigin string
|
||||
requestHeaders map[string]string
|
||||
wantStatus int
|
||||
wantHeaders http.Header
|
||||
expectNext bool
|
||||
}{
|
||||
{
|
||||
name: "OPTIONS request with allowed origin",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestHeaders: map[string]string{},
|
||||
requestOrigin: "https://example.com",
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Origin": []string{"https://example.com"},
|
||||
"Access-Control-Allow-Methods": []string{"GET, POST"},
|
||||
"Access-Control-Allow-Headers": []string{"Content-Type, Authorization"},
|
||||
"Access-Control-Allow-Credentials": []string{"false"},
|
||||
"Vary": []string{
|
||||
"Origin, Access-Control-Request-Method",
|
||||
},
|
||||
"Content-Length": []string{"0"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with wildcard origin",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestHeaders: map[string]string{},
|
||||
requestOrigin: "https://any-origin.com",
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Origin": []string{"https://any-origin.com"},
|
||||
"Access-Control-Allow-Methods": []string{"GET, POST, PUT, DELETE"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with disallowed origin",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestHeaders: map[string]string{},
|
||||
requestOrigin: "https://malicious.com",
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with reflected headers (nil)",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"POST"},
|
||||
AllowedHeaders: nil,
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{
|
||||
"Access-Control-Request-Headers": "X-Custom-Header, X-Another-Header",
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Headers": []string{"X-Custom-Header, X-Another-Header"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with denied headers (empty slice)",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"POST"},
|
||||
AllowedHeaders: []string{},
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{
|
||||
"Access-Control-Request-Headers": "X-Custom-Header, X-Another-Header",
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with nil headers and no request headers",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: nil,
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with credentials enabled",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowCredentials: true,
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Credentials": []string{"true"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with MaxAge",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
MaxAge: "3600",
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Max-Age": []string{"3600"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request with exposed headers",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
ExposedHeaders: []string{"X-Custom-Response", "X-Total-Count"},
|
||||
},
|
||||
requestMethod: "OPTIONS",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Expose-Headers": []string{"X-Custom-Response, X-Total-Count"},
|
||||
},
|
||||
expectNext: false,
|
||||
},
|
||||
{
|
||||
name: "GET request with allowed origin",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
},
|
||||
requestMethod: "GET",
|
||||
requestOrigin: "https://example.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Origin": []string{"https://example.com"},
|
||||
"Access-Control-Allow-Methods": []string{"GET, POST"},
|
||||
"Access-Control-Allow-Headers": []string{"Content-Type"},
|
||||
},
|
||||
expectNext: true,
|
||||
},
|
||||
{
|
||||
name: "POST request with disallowed origin",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
},
|
||||
requestMethod: "POST",
|
||||
requestOrigin: "https://malicious.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: true,
|
||||
},
|
||||
{
|
||||
name: "GET request without origin header",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
},
|
||||
requestMethod: "GET",
|
||||
requestOrigin: "",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: true,
|
||||
},
|
||||
{
|
||||
name: "GET request with empty allowed origins (denies all)",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{},
|
||||
AllowedMethods: []string{"GET"},
|
||||
},
|
||||
requestMethod: "GET",
|
||||
requestOrigin: "https://any-origin.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{},
|
||||
expectNext: true,
|
||||
},
|
||||
{
|
||||
name: "GET request with nil allowed origins (allows all)",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: nil,
|
||||
AllowedMethods: []string{"GET"},
|
||||
},
|
||||
requestMethod: "GET",
|
||||
requestOrigin: "https://any-origin.com",
|
||||
requestHeaders: map[string]string{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Origin": []string{"https://any-origin.com"},
|
||||
},
|
||||
expectNext: true,
|
||||
},
|
||||
{
|
||||
name: "GET request with multiple allowed origins",
|
||||
opts: middleware.CORSOptions{ //nolint:exhaustruct
|
||||
AllowedOrigins: []string{
|
||||
"https://example.com",
|
||||
"https://another-example.com",
|
||||
"https://third-example.com",
|
||||
},
|
||||
AllowedMethods: []string{"GET"},
|
||||
},
|
||||
requestMethod: "GET",
|
||||
requestHeaders: map[string]string{},
|
||||
requestOrigin: "https://another-example.com",
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: http.Header{
|
||||
"Access-Control-Allow-Origin": []string{"https://another-example.com"},
|
||||
},
|
||||
expectNext: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup router with CORS middleware
|
||||
router := gin.New()
|
||||
nextCalled := false
|
||||
|
||||
router.Use(middleware.CORS(tc.opts))
|
||||
router.Any("/test", func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(tc.requestMethod, "/test", nil)
|
||||
if tc.requestOrigin != "" {
|
||||
req.Header.Set("Origin", tc.requestOrigin)
|
||||
}
|
||||
|
||||
// Add any additional request headers
|
||||
for key, value := range tc.requestHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// Record response
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Check status code
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tc.wantStatus, w.Code)
|
||||
}
|
||||
|
||||
// Check expected headers using cmp.Diff
|
||||
// Only compare headers that are expected
|
||||
gotHeaders := make(http.Header)
|
||||
for key := range tc.wantHeaders {
|
||||
if values := w.Header().Values(key); len(values) > 0 {
|
||||
gotHeaders[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tc.wantHeaders, gotHeaders); diff != "" {
|
||||
t.Errorf("response headers mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Check if Next() was called
|
||||
if nextCalled != tc.expectNext {
|
||||
t.Errorf("expected Next() called to be %v, got %v", tc.expectNext, nextCalled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func LoggerFromContext(ctx context.Context) *slog.Logger { //nolint:contextcheck
|
||||
return logger
|
||||
}
|
||||
|
||||
// Logger is a Gin middleware that logs HTTP requests and responses using slog.
|
||||
func Logger(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
startTime := time.Now()
|
||||
69
internal/lib/oapi/oapi.go
Normal file
69
internal/lib/oapi/oapi.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package oapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/example/api"
|
||||
"github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
)
|
||||
|
||||
func surfaceErrorsMiddleWare(c *gin.Context) {
|
||||
// this captures two cases as far as I can see:
|
||||
// 1. request validation errors where the strict generated code fails
|
||||
// to bind the request to the struct (i.e. "invalid param" test)
|
||||
// 2. when a handler returns an error instead of a response
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) > 0 && !c.IsAborted() {
|
||||
var errorCode string
|
||||
switch c.Writer.Status() {
|
||||
case http.StatusBadRequest:
|
||||
errorCode = "bad-request"
|
||||
default:
|
||||
errorCode = "internal-server-error"
|
||||
}
|
||||
|
||||
c.JSON(
|
||||
c.Writer.Status(),
|
||||
gin.H{"errors": errorCode, "message": c.Errors[0].Error()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NewRouter creates a Gin router with OpenAPI request validation middleware.
|
||||
func NewRouter(
|
||||
schema []byte,
|
||||
apiPrefix string,
|
||||
authenticationFunc openapi3filter.AuthenticationFunc,
|
||||
corsOptions middleware.CORSOptions,
|
||||
logger *slog.Logger,
|
||||
) (*gin.Engine, func(c *gin.Context), error) {
|
||||
router := gin.New()
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
|
||||
doc, err := loader.LoadFromData(schema)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load OpenAPI schema: %w", err)
|
||||
}
|
||||
|
||||
doc.AddServer(&openapi3.Server{ //nolint:exhaustruct
|
||||
URL: apiPrefix,
|
||||
})
|
||||
|
||||
router.Use(
|
||||
gin.Recovery(),
|
||||
surfaceErrorsMiddleWare,
|
||||
middleware.Logger(logger),
|
||||
middleware.CORS(corsOptions),
|
||||
)
|
||||
|
||||
mw := api.MiddlewareFunc(requestValidatorWithOptions(doc, authenticationFunc))
|
||||
|
||||
return router, mw, nil
|
||||
}
|
||||
128
internal/lib/oapi/request.go
Normal file
128
internal/lib/oapi/request.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package oapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
GinContextKey ContextKey = "nhost-oapi/gin-context"
|
||||
)
|
||||
|
||||
func handleError(c *gin.Context, err error) {
|
||||
var (
|
||||
errReq *openapi3filter.RequestError
|
||||
errSchema *openapi3.SchemaError
|
||||
errAuth *AuthenticatorError
|
||||
errSec *openapi3filter.SecurityRequirementsError
|
||||
)
|
||||
switch {
|
||||
case errors.As(err, &errSchema):
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "schema-validation-error",
|
||||
"reason": errSchema.Reason,
|
||||
})
|
||||
case errors.As(err, &errReq):
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "request-validation-error",
|
||||
"reason": errReq.Err.Error(),
|
||||
})
|
||||
case errors.As(err, &errAuth):
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": errAuth.Code,
|
||||
"reason": errAuth.Message,
|
||||
"securityScheme": errAuth.Scheme,
|
||||
})
|
||||
case errors.As(err, &errSec):
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "unauthorized",
|
||||
"reason": errSec.Error(),
|
||||
})
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func requestValidatorWithOptions(
|
||||
swagger *openapi3.T,
|
||||
authFn openapi3filter.AuthenticationFunc,
|
||||
) gin.HandlerFunc {
|
||||
router, err := gorillamux.NewRouter(swagger)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if err := validateRequestFromContext(c, router, authFn); err != nil {
|
||||
handleError(c, err)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func validateRequestFromContext(
|
||||
c *gin.Context,
|
||||
router routers.Router,
|
||||
authFn openapi3filter.AuthenticationFunc,
|
||||
) error {
|
||||
route, pathParams, err := router.FindRoute(c.Request)
|
||||
if err != nil {
|
||||
var e *routers.RouteError
|
||||
switch {
|
||||
case errors.As(err, &e):
|
||||
return e
|
||||
default:
|
||||
return fmt.Errorf("error validating route: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
validationInput := &openapi3filter.RequestValidationInput{ //nolint:exhaustruct
|
||||
Request: c.Request,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
AuthenticationFunc: authFn,
|
||||
ExcludeRequestBody: false,
|
||||
ExcludeRequestQueryParams: false,
|
||||
ExcludeResponseBody: false,
|
||||
ExcludeReadOnlyValidations: false,
|
||||
ExcludeWriteOnlyValidations: false,
|
||||
IncludeResponseStatus: false,
|
||||
MultiError: false,
|
||||
RegexCompiler: nil,
|
||||
SkipSettingDefaults: false,
|
||||
},
|
||||
}
|
||||
|
||||
requestContext := context.WithValue(c.Request.Context(), GinContextKey, c)
|
||||
if err := openapi3filter.ValidateRequest(requestContext, validationInput); err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetGinContext(c context.Context) *gin.Context {
|
||||
v := c.Value(GinContextKey)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ginCtx, ok := v.(*gin.Context)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ginCtx
|
||||
}
|
||||
@@ -114,13 +114,13 @@ in
|
||||
echo "➜ Running golangci-lint"
|
||||
golangci-lint run \
|
||||
--timeout 600s \
|
||||
./${submodule}/...
|
||||
./...
|
||||
|
||||
echo "➜ Running tests"
|
||||
richgo test \
|
||||
-tags="${pkgs.lib.strings.concatStringsSep " " tags}" \
|
||||
-ldflags="${pkgs.lib.strings.concatStringsSep " " ldflags}" \
|
||||
-v ${goTestFlags} ./${submodule}/...
|
||||
-v ${goTestFlags} ./...
|
||||
|
||||
${extraCheck}
|
||||
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
## [@nhost/nhost-js@4.1.0] - 2025-11-04
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(nhost-js)* Added pushChainFunction to functions and graphql clients (#3610)
|
||||
- *(nhost-js)* Added various middlewares to work with headers and customizable createNhostClient (#3612)
|
||||
- *(auth)* Added endpoints to retrieve and refresh oauth2 providers' tokens (#3614)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(dashboard)* Run audit and lint in dashboard (#3578)
|
||||
- *(nhost-js)* Improvements to Session guard to avoid conflict with ProviderSession (#3662)
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(nhost-js)* Generate code from local API definitions (#3583)
|
||||
- *(docs)* Udpated README.md and CONTRIBUTING.md (#3587)
|
||||
- *(nhost-js)* Regenerate types (#3648)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -337,7 +337,8 @@ export type ErrorResponseError =
|
||||
| "oauth-profile-fetch-failed"
|
||||
| "oauth-provider-error"
|
||||
| "invalid-otp"
|
||||
| "cannot-send-sms";
|
||||
| "cannot-send-sms"
|
||||
| "provider-account-already-linked";
|
||||
|
||||
/**
|
||||
* Standardized error response
|
||||
|
||||
@@ -46,7 +46,7 @@ export const updateSessionFromResponseMiddleware = (
|
||||
return body.session || null;
|
||||
}
|
||||
|
||||
if ("accessToken" in body && "refreshToken" in body) {
|
||||
if ("accessToken" in body && "refreshToken" in body && "user" in body) {
|
||||
// Session
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
## [auth@0.43.1] - 2025-11-11
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(auth)* Return meaningful error if the provider's account is already linked (#3680)
|
||||
|
||||
## [auth@0.43.0] - 2025-11-04
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(auth)* Encrypt TOTP secret (#3619)
|
||||
- *(auth)* Added endpoints to retrieve and refresh oauth2 providers' tokens (#3614)
|
||||
- *(auth)* If the callback state is wrong send back to the redirectTo as provider_state (#3649)
|
||||
- *(internal/lib)* Common oapi middleware for go services (#3663)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(auth)* Dont mutate client URL (#3660)
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(docs)* Fix broken link in openapi spec and minor mistakes in postmark integration info (#3621)
|
||||
- *(nixops)* Bump go to 1.25.3 and nixpkgs due to CVEs (#3652)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -115,13 +115,13 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) YourEndpoint( //nolint:ireturn
|
||||
ctx context.Context, request api.YourEndpointRequestObject,
|
||||
) (api.YourEndpointResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
// Validate inputs
|
||||
if apiErr := ctrl.wf.ValidateInput(request.Body.Field, logger); apiErr != nil {
|
||||
|
||||
@@ -1665,6 +1665,7 @@ components:
|
||||
- oauth-provider-error
|
||||
- invalid-otp
|
||||
- cannot-send-sms
|
||||
- provider-account-already-linked
|
||||
required:
|
||||
- status
|
||||
- message
|
||||
|
||||
@@ -3785,147 +3785,147 @@ var swaggerSpec = []string{
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -84,6 +84,7 @@ const (
|
||||
OauthTokenEchangeFailed ErrorResponseError = "oauth-token-echange-failed"
|
||||
PasswordInHibpDatabase ErrorResponseError = "password-in-hibp-database"
|
||||
PasswordTooShort ErrorResponseError = "password-too-short"
|
||||
ProviderAccountAlreadyLinked ErrorResponseError = "provider-account-already-linked"
|
||||
RedirectToNotAllowed ErrorResponseError = "redirectTo-not-allowed"
|
||||
RoleNotAllowed ErrorResponseError = "role-not-allowed"
|
||||
SignupDisabled ErrorResponseError = "signup-disabled"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func cors() gin.HandlerFunc {
|
||||
f := func(c *gin.Context, origin string) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "POST, GET")
|
||||
headers := c.Request.Header.Get("Access-Control-Request-Headers")
|
||||
c.Header("Access-Control-Allow-Headers", headers)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
c.Writer.Header().Add("Vary", "Origin, Access-Control-Request-Method")
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
f(c, origin)
|
||||
|
||||
c.Header("Content-Length", "0")
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
}
|
||||
|
||||
if origin != "" {
|
||||
f(c, origin)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bradfitz/gomemcache/memcache"
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nhost/nhost/internal/lib/oapi"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/docs"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/controller"
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/nhost/nhost/services/auth/go/oidc"
|
||||
"github.com/nhost/nhost/services/auth/go/providers"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
ginmiddleware "github.com/oapi-codegen/gin-middleware"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -1296,87 +1295,42 @@ func getDependencies( //nolint:ireturn
|
||||
return emailer, sms, jwtGetter, idTokenValidator, nil
|
||||
}
|
||||
|
||||
func getGoServer( //nolint:funlen
|
||||
func getCORSOptions() oapimw.CORSOptions {
|
||||
return oapimw.CORSOptions{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"POST", "GET"},
|
||||
AllowedHeaders: nil,
|
||||
ExposedHeaders: []string{},
|
||||
AllowCredentials: true,
|
||||
MaxAge: "86400",
|
||||
}
|
||||
}
|
||||
|
||||
func getGoServer(
|
||||
ctx context.Context,
|
||||
cmd *cli.Command,
|
||||
db *sql.Queries,
|
||||
encrypter *crypto.Encrypter,
|
||||
logger *slog.Logger,
|
||||
) (*http.Server, error) {
|
||||
router := gin.New()
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
|
||||
doc, err := loader.LoadFromData(docs.OpenAPISchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OpenAPI schema: %w", err)
|
||||
}
|
||||
|
||||
doc.AddServer(&openapi3.Server{ //nolint:exhaustruct
|
||||
URL: cmd.String(flagAPIPrefix),
|
||||
})
|
||||
|
||||
handlers := []gin.HandlerFunc{
|
||||
// ginmiddleware.OapiRequestValidator(doc),
|
||||
gin.Recovery(),
|
||||
cors(),
|
||||
middleware.Logger(logger), //nolint:contextcheck
|
||||
}
|
||||
|
||||
if cmd.Bool(flagRateLimitEnable) {
|
||||
handlers = append(handlers, getRateLimiter(cmd, logger)) //nolint:contextcheck
|
||||
}
|
||||
|
||||
if cmd.String(flagTurnstileSecret) != "" {
|
||||
handlers = append(handlers, middleware.Tunrstile( //nolint:contextcheck
|
||||
cmd.String(flagTurnstileSecret), cmd.String(flagAPIPrefix)),
|
||||
)
|
||||
}
|
||||
|
||||
router.Use(handlers...)
|
||||
|
||||
config, err := getConfig(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("problem creating config: %w", err)
|
||||
}
|
||||
|
||||
emailer, smsClient, jwtGetter, idTokenValidator, err := getDependencies(ctx, cmd, db, logger)
|
||||
ctrl, jwtGetter, err := getController(ctx, cmd, db, encrypter, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oauthProviders, err := getOauth2Providers(ctx, cmd, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("problem creating oauth providers: %w", err)
|
||||
}
|
||||
handler := api.NewStrictHandler(ctrl, []api.StrictMiddlewareFunc{})
|
||||
|
||||
ctrl, err := controller.New(
|
||||
db,
|
||||
config,
|
||||
jwtGetter,
|
||||
emailer,
|
||||
smsClient,
|
||||
hibp.NewClient(),
|
||||
oauthProviders,
|
||||
idTokenValidator,
|
||||
controller.NewTotp(cmd.String(flagMfaTotpIssuer), time.Now),
|
||||
encrypter,
|
||||
cmd.Root().Version,
|
||||
router, mw, err := oapi.NewRouter( //nolint:contextcheck
|
||||
docs.OpenAPISchema,
|
||||
cmd.String(flagAPIPrefix),
|
||||
jwtGetter.MiddlewareFunc,
|
||||
getCORSOptions(),
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create controller: %w", err)
|
||||
return nil, fmt.Errorf("failed to create router: %w", err)
|
||||
}
|
||||
|
||||
handler := api.NewStrictHandler(ctrl, []api.StrictMiddlewareFunc{})
|
||||
mw := api.MiddlewareFunc(ginmiddleware.OapiRequestValidatorWithOptions(
|
||||
doc,
|
||||
&ginmiddleware.Options{ //nolint:exhaustruct
|
||||
Options: openapi3filter.Options{ //nolint:exhaustruct
|
||||
AuthenticationFunc: jwtGetter.MiddlewareFunc,
|
||||
},
|
||||
SilenceServersWarning: true,
|
||||
},
|
||||
))
|
||||
api.RegisterHandlersWithOptions(
|
||||
router,
|
||||
handler,
|
||||
@@ -1387,6 +1341,16 @@ func getGoServer( //nolint:funlen
|
||||
},
|
||||
)
|
||||
|
||||
if cmd.Bool(flagRateLimitEnable) {
|
||||
router.Use(getRateLimiter(cmd, logger)) //nolint:contextcheck
|
||||
}
|
||||
|
||||
if cmd.String(flagTurnstileSecret) != "" {
|
||||
router.Use(middleware.Tunrstile( //nolint:contextcheck
|
||||
cmd.String(flagTurnstileSecret), cmd.String(flagAPIPrefix),
|
||||
))
|
||||
}
|
||||
|
||||
if cmd.Bool(flagEnableChangeEnv) {
|
||||
router.POST(cmd.String(flagAPIPrefix)+"/change-env", ctrl.PostChangeEnv)
|
||||
}
|
||||
@@ -1410,6 +1374,48 @@ func getGoServer( //nolint:funlen
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func getController(
|
||||
ctx context.Context,
|
||||
cmd *cli.Command,
|
||||
db *sql.Queries,
|
||||
encrypter *crypto.Encrypter,
|
||||
logger *slog.Logger,
|
||||
) (*controller.Controller, *controller.JWTGetter, error) {
|
||||
config, err := getConfig(cmd)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("problem creating config: %w", err)
|
||||
}
|
||||
|
||||
emailer, smsClient, jwtGetter, idTokenValidator, err := getDependencies(ctx, cmd, db, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
oauthProviders, err := getOauth2Providers(ctx, cmd, logger)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("problem creating oauth providers: %w", err)
|
||||
}
|
||||
|
||||
ctrl, err := controller.New(
|
||||
db,
|
||||
config,
|
||||
jwtGetter,
|
||||
emailer,
|
||||
smsClient,
|
||||
hibp.NewClient(),
|
||||
oauthProviders,
|
||||
idTokenValidator,
|
||||
controller.NewTotp(cmd.String(flagMfaTotpIssuer), time.Now),
|
||||
encrypter,
|
||||
cmd.Root().Version,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create controller: %w", err)
|
||||
}
|
||||
|
||||
return ctrl, jwtGetter, nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, cmd *cli.Command) error {
|
||||
logger := getLogger(cmd.Bool(flagDebug), cmd.Bool(flagLogFormatTEXT))
|
||||
logger.InfoContext(ctx, cmd.Root().Name+" v"+cmd.Root().Version)
|
||||
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) AddSecurityKey( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
_ api.AddSecurityKeyRequestObject,
|
||||
) (api.AddSecurityKeyResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
if !ctrl.config.WebauthnEnabled {
|
||||
logger.ErrorContext(ctx, "webauthn is disabled")
|
||||
|
||||
@@ -3,15 +3,15 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) ChangeUserEmail( //nolint:ireturn
|
||||
ctx context.Context, request api.ChangeUserEmailRequestObject,
|
||||
) (api.ChangeUserEmailResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
options, apiErr := ctrl.wf.ValidateOptionsRedirectTo(ctx, request.Body.Options, logger)
|
||||
if apiErr != nil {
|
||||
|
||||
@@ -3,15 +3,15 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) ChangeUserMfa( //nolint:ireturn
|
||||
ctx context.Context, _ api.ChangeUserMfaRequestObject,
|
||||
) (api.ChangeUserMfaResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
if !ctrl.config.MfaEnabled {
|
||||
logger.WarnContext(ctx, "mfa disabled")
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) postUserPasswordAuthenticated( //nolint:ireturn
|
||||
@@ -61,7 +61,7 @@ func (ctrl *Controller) ChangeUserPassword( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.ChangeUserPasswordRequestObject,
|
||||
) (api.ChangeUserPasswordResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
jwtToken, ok := ctrl.wf.jwtGetter.FromContext(ctx)
|
||||
if ok {
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) CreatePAT( //nolint:ireturn
|
||||
ctx context.Context, request api.CreatePATRequestObject,
|
||||
) (api.CreatePATResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
user, apiErr := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
|
||||
if apiErr != nil {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func (ctrl *Controller) postUserDeanonymizeValidateRequest( //nolint:cyclop
|
||||
func (ctrl *Controller) DeanonymizeUser( //nolint:funlen
|
||||
ctx context.Context, request api.DeanonymizeUserRequestObject,
|
||||
) (api.DeanonymizeUserResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
userID, password, options, apiError := ctrl.postUserDeanonymizeValidateRequest(
|
||||
|
||||
@@ -3,15 +3,15 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) ElevateWebauthn( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
_ api.ElevateWebauthnRequestObject,
|
||||
) (api.ElevateWebauthnResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
if !ctrl.config.WebauthnEnabled {
|
||||
logger.ErrorContext(ctx, "webauthn is disabled")
|
||||
|
||||
@@ -21,8 +21,6 @@ func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("API error: %s", e.t)
|
||||
}
|
||||
|
||||
var ErrElevatedClaimRequired = errors.New("elevated-claim-required")
|
||||
|
||||
var (
|
||||
ErrJWTConfiguration = errors.New("jwt-configuration")
|
||||
|
||||
@@ -32,7 +30,7 @@ var (
|
||||
ErrInvalidOTP = &APIError{api.InvalidRequest}
|
||||
ErrUserProviderNotFound = &APIError{api.InvalidRequest}
|
||||
ErrSecurityKeyNotFound = &APIError{api.InvalidRequest}
|
||||
ErrUserProviderAlreadyLinked = &APIError{api.InvalidRequest}
|
||||
ErrProviderAccountAlreadyLinked = &APIError{api.ProviderAccountAlreadyLinked}
|
||||
ErrEmailAlreadyInUse = &APIError{api.EmailAlreadyInUse}
|
||||
ErrForbiddenAnonymous = &APIError{api.ForbiddenAnonymous}
|
||||
ErrInternalServerError = &APIError{api.InternalServerError}
|
||||
@@ -273,14 +271,17 @@ func isSensitive(err api.ErrorResponseError) bool {
|
||||
api.OauthTokenEchangeFailed,
|
||||
api.OauthProfileFetchFailed,
|
||||
api.CannotSendSms,
|
||||
api.OauthProviderError:
|
||||
api.OauthProviderError,
|
||||
api.ProviderAccountAlreadyLinked:
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (ctrl *Controller) getError(err *APIError) ErrorResponse { //nolint:gocyclo,cyclop,funlen
|
||||
func (ctrl *Controller) getError( //nolint:gocyclo,cyclop,funlen,maintidx
|
||||
err *APIError,
|
||||
) ErrorResponse {
|
||||
invalidRequest := ErrorResponse{
|
||||
Status: http.StatusBadRequest,
|
||||
Error: api.InvalidRequest,
|
||||
@@ -473,6 +474,12 @@ func (ctrl *Controller) getError(err *APIError) ErrorResponse { //nolint:gocyclo
|
||||
Error: err.t,
|
||||
Message: "Invalid or expired OTP",
|
||||
}
|
||||
case api.ProviderAccountAlreadyLinked:
|
||||
return ErrorResponse{
|
||||
Status: http.StatusBadRequest,
|
||||
Error: err.t,
|
||||
Message: "This provider account is already linked to a user",
|
||||
}
|
||||
}
|
||||
|
||||
return invalidRequest
|
||||
@@ -525,7 +532,7 @@ func (ctrl *Controller) sendRedirectError(
|
||||
) ErrorRedirectResponse {
|
||||
errResponse := ctrl.getError(err)
|
||||
|
||||
redirectURL = generateRedirectURL(redirectURL, map[string]string{
|
||||
redirectURL = appendURLValues(redirectURL, map[string]string{
|
||||
"error": string(errResponse.Error),
|
||||
"errorDescription": errResponse.Message,
|
||||
})
|
||||
@@ -567,17 +574,3 @@ func sqlIsDuplcateError(err error, fkey string) bool {
|
||||
return strings.Contains(err.Error(), "SQLSTATE 23505") &&
|
||||
strings.Contains(err.Error(), fkey)
|
||||
}
|
||||
|
||||
func generateRedirectURL(
|
||||
redirectTo *url.URL,
|
||||
opts map[string]string,
|
||||
) *url.URL {
|
||||
q := redirectTo.Query()
|
||||
for k, v := range opts {
|
||||
q.Set(k, v)
|
||||
}
|
||||
|
||||
redirectTo.RawQuery = q.Encode()
|
||||
|
||||
return redirectTo
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ func (ctrl *Controller) GetProviderTokens( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
req api.GetProviderTokensRequestObject,
|
||||
) (api.GetProviderTokensResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
logger = logger.With("provider", req.Provider)
|
||||
|
||||
user, apiErr := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) GetUser( //nolint:ireturn
|
||||
ctx context.Context, _ api.GetUserRequestObject,
|
||||
) (api.GetUserResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
// Get authenticated user from JWT
|
||||
user, apiErr := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
@@ -17,8 +16,8 @@ import (
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/internal/lib/oapi"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
ginmiddleware "github.com/oapi-codegen/gin-middleware"
|
||||
)
|
||||
|
||||
const JWTContextKey = "nhost/auth/jwt"
|
||||
@@ -340,7 +339,7 @@ func (j *JWTGetter) Validate(accessToken string) (*jwt.Token, error) {
|
||||
func (j *JWTGetter) FromContext(ctx context.Context) (*jwt.Token, bool) {
|
||||
token, ok := ctx.Value(JWTContextKey).(*jwt.Token)
|
||||
if !ok { //nolint:nestif
|
||||
c := ginmiddleware.GetGinContext(ctx)
|
||||
c := oapi.GetGinContext(ctx)
|
||||
if c != nil {
|
||||
a, ok := c.Get(JWTContextKey)
|
||||
if !ok {
|
||||
@@ -415,16 +414,28 @@ func (j *JWTGetter) MiddlewareFunc(
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return errors.New("invalid authorization header") //nolint:err113
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: "missing or malformed authorization header",
|
||||
}
|
||||
}
|
||||
|
||||
jwtToken, err := j.Validate(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error validating token: %w", err)
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: fmt.Sprintf("error validating token: %s", err),
|
||||
}
|
||||
}
|
||||
|
||||
if !jwtToken.Valid {
|
||||
return errors.New("invalid token") //nolint:err113
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: "invalid token",
|
||||
}
|
||||
}
|
||||
|
||||
if input.SecuritySchemeName == "BearerAuthElevated" {
|
||||
@@ -435,15 +446,23 @@ func (j *JWTGetter) MiddlewareFunc(
|
||||
|
||||
found, err := j.verifyElevatedClaim(ctx, jwtToken, requestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error verifying elevated claim: %w", err)
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: fmt.Sprintf("error verifying elevated claim: %s", err),
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrElevatedClaimRequired
|
||||
return &oapi.AuthenticatorError{
|
||||
Scheme: input.SecuritySchemeName,
|
||||
Code: "unauthorized",
|
||||
Message: "elevated claim required",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c := ginmiddleware.GetGinContext(ctx)
|
||||
c := oapi.GetGinContext(ctx)
|
||||
c.Set(JWTContextKey, jwtToken)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,7 +3,6 @@ package controller_test
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -16,9 +15,9 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/internal/lib/oapi"
|
||||
"github.com/nhost/nhost/services/auth/go/controller"
|
||||
"github.com/nhost/nhost/services/auth/go/controller/mock"
|
||||
ginmiddleware "github.com/oapi-codegen/gin-middleware"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
@@ -535,8 +534,12 @@ func TestMiddlewareFunc(t *testing.T) { //nolint:maintidx
|
||||
SecurityScheme: nil,
|
||||
Scopes: []string{},
|
||||
},
|
||||
expected: nil,
|
||||
expectedErr: controller.ErrElevatedClaimRequired,
|
||||
expected: nil,
|
||||
expectedErr: &oapi.AuthenticatorError{
|
||||
Scheme: "BearerAuthElevated",
|
||||
Code: "unauthorized",
|
||||
Message: "elevated claim required",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -559,8 +562,12 @@ func TestMiddlewareFunc(t *testing.T) { //nolint:maintidx
|
||||
SecurityScheme: nil,
|
||||
Scopes: []string{},
|
||||
},
|
||||
expected: nil,
|
||||
expectedErr: controller.ErrElevatedClaimRequired,
|
||||
expected: nil,
|
||||
expectedErr: &oapi.AuthenticatorError{
|
||||
Scheme: "BearerAuthElevated",
|
||||
Code: "unauthorized",
|
||||
Message: "elevated claim required",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -763,13 +770,13 @@ func TestMiddlewareFunc(t *testing.T) { //nolint:maintidx
|
||||
//nolint
|
||||
ctx := context.WithValue(
|
||||
context.Background(),
|
||||
ginmiddleware.GinContextKey,
|
||||
oapi.GinContextKey,
|
||||
&gin.Context{},
|
||||
)
|
||||
|
||||
err = jwtGetter.MiddlewareFunc(ctx, tc.request)
|
||||
if !errors.Is(err, tc.expectedErr) {
|
||||
t.Errorf("err = %v; want %v", err, tc.expectedErr)
|
||||
if diff := cmp.Diff(err, tc.expectedErr); diff != "" {
|
||||
t.Errorf("err mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
got, _ := jwtGetter.FromContext(ctx)
|
||||
|
||||
@@ -3,14 +3,14 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) LinkIdToken( //nolint:ireturn,revive
|
||||
ctx context.Context, req api.LinkIdTokenRequestObject,
|
||||
) (api.LinkIdTokenResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
profile, apiErr := ctrl.wf.GetOIDCProfileFromIDToken(
|
||||
ctx,
|
||||
|
||||
@@ -332,8 +332,8 @@ func TestLinkIdToken(t *testing.T) { //nolint:maintidx
|
||||
},
|
||||
},
|
||||
expectedResponse: controller.ErrorResponse{
|
||||
Error: "invalid-request",
|
||||
Message: "The request payload is incorrect",
|
||||
Error: "provider-account-already-linked",
|
||||
Message: "This provider account is already linked to a user",
|
||||
Status: 400,
|
||||
},
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) RefreshProviderToken( //nolint:ireturn
|
||||
ctx context.Context, req api.RefreshProviderTokenRequestObject,
|
||||
) (api.RefreshProviderTokenResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
logger = logger.With("provider", req.Provider)
|
||||
|
||||
provider := ctrl.Providers.Get(string(req.Provider))
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) RefreshToken( //nolint:ireturn
|
||||
ctx context.Context, request api.RefreshTokenRequestObject,
|
||||
) (api.RefreshTokenResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
user, apiErr := ctrl.wf.GetUserByRefreshTokenHash(
|
||||
ctx,
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ func (ctrl *Controller) SendPasswordResetEmail( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SendPasswordResetEmailRequestObject,
|
||||
) (api.SendPasswordResetEmailResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
options, err := ctrl.wf.ValidateOptionsRedirectTo(ctx, request.Body.Options, logger)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ func (ctrl *Controller) SendVerificationEmail( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SendVerificationEmailRequestObject,
|
||||
) (api.SendVerificationEmailResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
options, apiErr := ctrl.wf.ValidateOptionsRedirectTo(ctx, request.Body.Options, logger)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) postSigninAnonymousValidateRequest(
|
||||
@@ -49,7 +49,7 @@ func (ctrl *Controller) postSigninAnonymousValidateRequest(
|
||||
func (ctrl *Controller) SignInAnonymous( //nolint:ireturn
|
||||
ctx context.Context, req api.SignInAnonymousRequestObject,
|
||||
) (api.SignInAnonymousResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
req, apiErr := ctrl.postSigninAnonymousValidateRequest(ctx, req, logger)
|
||||
if apiErr != nil {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) postSigninEmailPasswordWithTOTP( //nolint:ireturn
|
||||
@@ -33,7 +33,7 @@ func (ctrl *Controller) postSigninEmailPasswordWithTOTP( //nolint:ireturn
|
||||
func (ctrl *Controller) SignInEmailPassword( //nolint:ireturn
|
||||
ctx context.Context, request api.SignInEmailPasswordRequestObject,
|
||||
) (api.SignInEmailPasswordResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
user, apiErr := ctrl.wf.GetUserByEmail(ctx, string(request.Body.Email), logger)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/oidc"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
"github.com/oapi-codegen/runtime/types"
|
||||
@@ -45,7 +45,7 @@ func (ctrl *Controller) postSigninIdtokenCheckUserExists(
|
||||
func (ctrl *Controller) SignInIdToken( //nolint:ireturn,revive
|
||||
ctx context.Context, req api.SignInIdTokenRequestObject,
|
||||
) (api.SignInIdTokenResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
profile, apiError := ctrl.wf.GetOIDCProfileFromIDToken(
|
||||
ctx,
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ func (ctrl *Controller) SignInOTPEmail( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SignInOTPEmailRequestObject,
|
||||
) (api.SignInOTPEmailResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
if !ctrl.config.OTPEmailEnabled {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/notifications"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
@@ -19,7 +19,7 @@ func (ctrl *Controller) SignInPasswordlessEmail( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SignInPasswordlessEmailRequestObject,
|
||||
) (api.SignInPasswordlessEmailResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("email", string(request.Body.Email)))
|
||||
|
||||
if !ctrl.config.EmailPasswordlessEnabled {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ func (ctrl *Controller) SignInPasswordlessSms( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SignInPasswordlessSmsRequestObject,
|
||||
) (api.SignInPasswordlessSmsResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("phoneNumber", request.Body.PhoneNumber))
|
||||
|
||||
if !ctrl.config.SMSPasswordlessEnabled {
|
||||
|
||||
@@ -3,8 +3,8 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/sql"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ func (ctrl *Controller) SignInPAT( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
request api.SignInPATRequestObject,
|
||||
) (api.SignInPATResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx)
|
||||
logger := oapimw.LoggerFromContext(ctx)
|
||||
|
||||
user, apiErr := ctrl.wf.GetUserByRefreshTokenHash(
|
||||
ctx,
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware"
|
||||
"github.com/nhost/nhost/services/auth/go/api"
|
||||
"github.com/nhost/nhost/services/auth/go/middleware"
|
||||
)
|
||||
|
||||
func (ctrl *Controller) getSigninProviderValidateRequest(
|
||||
@@ -42,7 +42,7 @@ func (ctrl *Controller) SignInProvider( //nolint:ireturn
|
||||
ctx context.Context,
|
||||
req api.SignInProviderRequestObject,
|
||||
) (api.SignInProviderResponseObject, error) {
|
||||
logger := middleware.LoggerFromContext(ctx).
|
||||
logger := oapimw.LoggerFromContext(ctx).
|
||||
With(slog.String("provider", string(req.Provider)))
|
||||
|
||||
redirectTo, apiErr := ctrl.getSigninProviderValidateRequest(ctx, req, logger)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user