diff --git a/.env.example b/.env.example index 100b0e9d..da0a0831 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,6 @@ TINYAUTH_SERVER_PORT=3000 TINYAUTH_SERVER_ADDRESS="0.0.0.0" # The path to the Unix socket. TINYAUTH_SERVER_SOCKETPATH= -# Enable listening on both TCP and Unix socket at the same time. -TINYAUTH_SERVER_CONCURRENTLISTENERSENABLED=false # auth config @@ -99,6 +97,8 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0 TINYAUTH_AUTH_LOGINTIMEOUT=300 # Maximum login retries. TINYAUTH_AUTH_LOGINMAXRETRIES=3 +# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically. +TINYAUTH_AUTH_LOCKDOWNENABLED=true # Comma-separated list of trusted proxy addresses. TINYAUTH_AUTH_TRUSTEDPROXIES= # ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow. @@ -206,6 +206,8 @@ TINYAUTH_LDAP_ADDRESS= TINYAUTH_LDAP_BINDDN= # Bind password for LDAP authentication. TINYAUTH_LDAP_BINDPASSWORD= +# Path to the Bind password. +TINYAUTH_LDAP_BINDPASSWORDFILE= # Base DN for LDAP searches. TINYAUTH_LDAP_BASEDN= # Allow insecure LDAP connections. @@ -252,3 +254,7 @@ TINYAUTH_TAILSCALE_HOSTNAME= TINYAUTH_TAILSCALE_AUTHKEY= # Use ephemeral Tailscale node. TINYAUTH_TAILSCALE_EPHEMERAL=false +# Enable Tailscale Funnel. +TINYAUTH_TAILSCALE_FUNNEL=false +# Listen on the Tailscale address instead of standard address. +TINYAUTH_TAILSCALE_LISTEN=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030064fb..a0ceceb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,17 +13,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: ./frontend/package.json - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "^1.26.0" + go-version: "^1.26.4" - name: Go dependencies run: go mod download @@ -62,6 +62,6 @@ jobs: run: go test -coverprofile=coverage.txt -v ./... - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index da7c0e0d..1046d913 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Delete old release run: gh release delete --cleanup-tag --yes nightly || echo release not found @@ -23,7 +23,7 @@ jobs: REPO: ${{ github.event.repository.name }} - name: Create release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: prerelease: true tag_name: nightly @@ -37,7 +37,7 @@ jobs: BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }} steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly @@ -55,19 +55,19 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: ./frontend/package.json - name: Install go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "^1.26.0" + go-version: "^1.26.4" - name: Install frontend dependencies working-directory: ./frontend @@ -100,19 +100,19 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: ./frontend/package.json - name: Install go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "^1.26.0" + go-version: "^1.26.4" - name: Install frontend dependencies working-directory: ./frontend @@ -145,7 +145,7 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly @@ -173,8 +173,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-amd64 + cache-to: type=gha,mode=max,scope=buildkit-amd64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} @@ -203,7 +203,7 @@ jobs: - image-build steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly @@ -232,8 +232,8 @@ jobs: tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true file: Dockerfile.distroless - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-distroless-amd64 + cache-to: type=gha,mode=max,scope=buildkit-distroless-amd64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} @@ -261,7 +261,7 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly @@ -289,8 +289,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-arm64 + cache-to: type=gha,mode=max,scope=buildkit-arm64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} @@ -319,7 +319,7 @@ jobs: - image-build-arm steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: nightly @@ -348,8 +348,8 @@ jobs: tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true file: Dockerfile.distroless - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-distroless-arm64 + cache-to: type=gha,mode=max,scope=buildkit-distroless-arm64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} @@ -461,7 +461,7 @@ jobs: merge-multiple: true - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: files: binaries/* tag_name: nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 980b337b..4e21ded9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }} steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate metadata id: metadata @@ -33,17 +33,17 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: ./frontend/package.json - name: Install go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "^1.26.0" + go-version: "^1.26.4" - name: Install frontend dependencies working-directory: ./frontend @@ -75,17 +75,17 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: ./frontend/package.json - name: Install go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "^1.26.0" + go-version: "^1.26.4" - name: Install frontend dependencies working-directory: ./frontend @@ -117,7 +117,7 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Docker meta id: meta @@ -143,14 +143,14 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-amd64 + cache-to: type=gha,mode=max,scope=buildkit-amd64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} - LDFLAGS="-s -w" + LDFLAGS=-s -w - name: Export digest run: | @@ -173,7 +173,7 @@ jobs: - image-build steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Docker meta id: meta @@ -200,14 +200,14 @@ jobs: tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true file: Dockerfile.distroless - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-distroless-amd64 + cache-to: type=gha,mode=max,scope=buildkit-distroless-amd64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} - LDFLAGS="-s -w" + LDFLAGS=-s -w - name: Export digest run: | @@ -229,7 +229,7 @@ jobs: - generate-metadata steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Docker meta id: meta @@ -255,14 +255,14 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-arm64 + cache-to: type=gha,mode=max,scope=buildkit-arm64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} - LDFLAGS="-s -w" + LDFLAGS=-s -w - name: Export digest run: | @@ -285,7 +285,7 @@ jobs: - image-build-arm steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Docker meta id: meta @@ -312,14 +312,14 @@ jobs: tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true file: Dockerfile.distroless - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=buildkit-distroless-arm64 + cache-to: type=gha,mode=max,scope=buildkit-distroless-arm64 github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} - LDFLAGS="-s -w" + LDFLAGS=-s -w - name: Export digest run: | @@ -432,6 +432,6 @@ jobs: merge-multiple: true - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: files: binaries/* diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5ef3741f..22e232ed 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: persist-credentials: false @@ -38,6 +38,6 @@ jobs: retention-days: 5 - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: sarif_file: results.sarif diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 84e12d1a..eb1429b7 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate Sponsors uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98aa87a3..a2b5f507 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a ## Requirements - pnpm -- Golang v1.24.0 or later +- Golang v1.26.4 or later - Git - Docker - Make diff --git a/Dockerfile b/Dockerfile index 65f82e2e..ed091586 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Site builder -FROM node:26.3-alpine3.23 AS frontend-builder +FROM node:26.4-alpine3.23 AS frontend-builder WORKDIR /frontend @@ -46,7 +46,7 @@ RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \ -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth # Runner -FROM alpine:3.23 AS runner +FROM alpine:3.24 AS runner WORKDIR /tinyauth diff --git a/Dockerfile.distroless b/Dockerfile.distroless index e9a43abb..64035fe7 100644 --- a/Dockerfile.distroless +++ b/Dockerfile.distroless @@ -1,5 +1,5 @@ # Site builder -FROM node:26.3-alpine3.23 AS frontend-builder +FROM node:26.4-alpine3.23 AS frontend-builder WORKDIR /frontend diff --git a/README.md b/README.md index 39422922..3aff6428 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,20 @@ If you like, you can help translate Tinyauth into more languages by visiting the Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file. + +## Hosting Partners + +If you use one of our partners, you can help support us while getting a great hosting deal. + +
+ +
+ ## Sponsors A big thank you to the following people for providing me with more coffee: -User avatar: erwinkramer  User avatar: nicotsx  User avatar: SimpleHomelab  User avatar: jmadden91  User avatar: tribor  User avatar: eliasbenb  User avatar: afunworm  User avatar: chip-well  User avatar: Lancelot-Enguerrand  User avatar: allgoewer  User avatar: NEANC  User avatar: ax-mad  User avatar: stegratech  User avatar: apearson   +User avatar: erwinkramer  User avatar: nicotsx  User avatar: SimpleHomelab  User avatar: jmadden91  User avatar: tribor  User avatar: eliasbenb  User avatar: afunworm  User avatar: chip-well  User avatar: Lancelot-Enguerrand  User avatar: allgoewer  User avatar: NEANC  User avatar: axjab  User avatar: stegratech  User avatar: apearson  User avatar: Micky5991   ## Acknowledgements diff --git a/frontend/src/components/icons/local-auth.tsx b/frontend/src/components/icons/local-auth.tsx new file mode 100644 index 00000000..d17391bd --- /dev/null +++ b/frontend/src/components/icons/local-auth.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +export function LocalAuthIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/language/language.tsx b/frontend/src/components/language/language.tsx deleted file mode 100644 index 3f0bf57a..00000000 --- a/frontend/src/components/language/language.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { languages, SupportedLanguage } from "@/lib/i18n/locales"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; -import { useState } from "react"; -import i18n from "@/lib/i18n/i18n"; - -export const LanguageSelector = () => { - const [language, setLanguage] = useState( - i18n.language as SupportedLanguage, - ); - - const handleSelect = (option: string) => { - setLanguage(option as SupportedLanguage); - i18n.changeLanguage(option as SupportedLanguage); - }; - - return ( - - ); -}; diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index d59aadf3..3139022f 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -1,9 +1,9 @@ import { useAppContext } from "@/context/app-context"; -import { LanguageSelector } from "../language/language"; import { Outlet } from "react-router"; import { useCallback, useEffect, useState } from "react"; import { DomainWarning } from "../domain-warning/domain-warning"; -import { ThemeToggle } from "../theme-toggle/theme-toggle"; +import { QuickActions } from "../quick-actions/quick-actions"; +import { isTrustedDomain } from "@/lib/hooks/redirect-uri"; const BaseLayout = ({ children }: { children: React.ReactNode }) => { const { ui } = useAppContext(); @@ -21,9 +21,8 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => { backgroundPosition: "center", }} > -
- - +
+
{children}
@@ -42,11 +41,18 @@ export const Layout = () => { setIgnoreDomainWarning(true); }, [setIgnoreDomainWarning]); - if ( - !ignoreDomainWarning && - ui.warningsEnabled && - !app.trustedDomains.includes(currentUrl) - ) { + const isTrusted = (() => { + try { + const appUrlObj = new URL(app.appUrl); + const currentUrlObj = new URL(currentUrl); + + return isTrustedDomain(currentUrlObj, appUrlObj, "", false); + } catch { + return false; + } + })(); + + if (!ignoreDomainWarning && ui.warningsEnabled && !isTrusted) { return ( = { + google: , + github: , + tailscale: , + microsoft: , + pocketid: , +}; + +export const QuickActions = () => { + const { auth, oauth, tailscale } = useUserContext(); + const { theme, setTheme } = useTheme(); + const { t } = useTranslation(); + const { search } = useLocation(); + + const [language, setLanguage] = useState( + i18n.language as SupportedLanguage, + ); + + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); + const screenParams = useScreenParams(searchParams); + const compiledParams = recompileScreenParams(screenParams); + + const [isOpen, setIsOpen] = useState(false); + + const providerDetails = ((): + | { name: string; icon: React.ReactNode } + | undefined => { + if (!auth.authenticated) { + return undefined; + } + + if (auth.providerId === "local" || auth.providerId === "ldap") { + return { + name: t( + auth.providerId === "ldap" + ? "quickActionsProviderLDAP" + : "quickActionsProviderLocal", + ), + icon: ( + + ), + }; + } + + if (oauth.active) { + return { + name: t("quickActionsProviderOAuth", { provider: oauth.displayName }), + icon: iconMap[auth.providerId] || , + }; + } + + if (auth.providerId === "tailscale") { + return { + name: `Tailscale (${tailscale.nodeName})`, + icon: , + }; + } + + return undefined; + })(); + + const logoutMutation = useMutation({ + mutationFn: () => axios.post("/api/user/logout"), + mutationKey: ["logout"], + onSuccess: () => { + toast.success(t("logoutSuccessTitle"), { + description: t("logoutSuccessSubtitle"), + }); + + redirectTimer.current = window.setTimeout(() => { + window.location.replace(`/login${compiledParams}`); + }, 500); + }, + onError: () => { + toast.error(t("logoutFailTitle"), { + description: t("logoutFailSubtitle"), + }); + }, + }); + + useEffect(() => { + return () => { + if (redirectTimer.current) { + clearTimeout(redirectTimer.current); + } + }; + }, [redirectTimer]); + + const initial = auth.authenticated + ? (auth.name[0] || "U").toUpperCase() + : null; + + const handleSelect = (option: string) => { + setLanguage(option as SupportedLanguage); + i18n.changeLanguage(option as SupportedLanguage); + }; + + const themes = [ + { key: "light", label: t("quickActionsThemeLight"), icon: Sun }, + { key: "dark", label: t("quickActionsThemeDark"), icon: Moon }, + { key: "system", label: t("quickActionsThemeSystem"), icon: Monitor }, + ] as const; + + return ( + setIsOpen(open)} open={isOpen}> + + + + + + {auth.authenticated && ( + <> + + + + {providerDetails!.icon} + + {providerDetails!.name} + +
+ + {auth.name} + + + {auth.email} + +
+
+ + + + )} + + + + + {t("quickActionsLanguage")} + + + + + {Object.entries(languages).map(([key, value]) => ( + handleSelect(key)} + > + {value} + {language === key && } + + ))} + + + + + + + + + {t("quickActionsTheme")} + + + + {themes.map(({ key, label, icon: Icon }) => ( + setTheme(key)}> + + + {label} + + {theme === key && } + + ))} + + + + + {auth.authenticated && ( + <> + + logoutMutation.mutate()} + className="text-destructive" + > + + {t("quickActionsLogout")} + + + )} +
+
+ ); +}; diff --git a/frontend/src/components/theme-toggle/theme-toggle.tsx b/frontend/src/components/theme-toggle/theme-toggle.tsx deleted file mode 100644 index c0791cfb..00000000 --- a/frontend/src/components/theme-toggle/theme-toggle.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Moon, Sun } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useTheme } from "@/components/providers/theme-provider"; - -export function ThemeToggle() { - const { setTheme } = useTheme(); - - return ( - - - - - - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - - - - ); -} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..e38a492f --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/lib/hooks/login-for.ts b/frontend/src/lib/hooks/login-for.ts new file mode 100644 index 00000000..8cf11579 --- /dev/null +++ b/frontend/src/lib/hooks/login-for.ts @@ -0,0 +1,17 @@ +type UseLoginForProps = { + login_for?: "oidc" | "app"; + compiledParams: string; +}; + +export const useLoginFor = (props: UseLoginForProps): string => { + const { login_for, compiledParams } = props; + + switch (login_for) { + case "oidc": + return "/oidc/authorize" + compiledParams; + case "app": + return "/continue" + compiledParams; + default: + return "/logout"; + } +}; diff --git a/frontend/src/lib/hooks/oidc.ts b/frontend/src/lib/hooks/oidc.ts deleted file mode 100644 index 1341e8c2..00000000 --- a/frontend/src/lib/hooks/oidc.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from "zod"; - -export const oidcParamsSchema = z.object({ - scope: z.string().min(1), - response_type: z.string().min(1), - client_id: z.string().min(1), - redirect_uri: z.string().min(1), - state: z.string().optional(), - nonce: z.string().optional(), - code_challenge: z.string().optional(), - code_challenge_method: z.string().optional(), -}); - -function b64urlDecode(s: string): string { - const base64 = s.replace(/-/g, "+").replace(/_/g, "/"); - return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); -} - -function decodeRequestObject(jwt: string): Record { - try { - // Must have exactly 3 parts: header, payload, signature - const parts = jwt.split("."); - if (parts.length !== 3) return {}; - - // Header must specify "alg": "none" and signature must be empty string - const header = JSON.parse(b64urlDecode(parts[0])); - if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {}; - - const payload = JSON.parse(b64urlDecode(parts[1])); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(payload)) { - if (typeof v === "string") result[k] = v; - } - return result; - } catch { - return {}; - } -} - -export const useOIDCParams = ( - params: URLSearchParams, -): { - values: z.infer; - issues: string[]; - isOidc: boolean; - compiled: string; -} => { - const obj = Object.fromEntries(params.entries()); - - // RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload - // and merge claims over top-level params (JWT claims take precedence) - const requestJwt = params.get("request"); - if (requestJwt) { - const claims = decodeRequestObject(requestJwt); - Object.assign(obj, claims); - } - - const parsed = oidcParamsSchema.safeParse(obj); - - if (parsed.success) { - return { - values: parsed.data, - issues: [], - isOidc: true, - compiled: new URLSearchParams(parsed.data).toString(), - }; - } - - return { - issues: parsed.error.issues.map((issue) => issue.path.toString()), - values: {} as z.infer, - isOidc: false, - compiled: "", - }; -}; diff --git a/frontend/src/lib/hooks/redirect-uri.ts b/frontend/src/lib/hooks/redirect-uri.ts index 5211178a..c4fc9a12 100644 --- a/frontend/src/lib/hooks/redirect-uri.ts +++ b/frontend/src/lib/hooks/redirect-uri.ts @@ -7,14 +7,29 @@ type IuseRedirectUri = { }; export const useRedirectUri = ( - redirect_uri: string | null, + redirect_uri: string | undefined, cookieDomain: string, + appUrl: string, + subdomainsEnabled: boolean, ): IuseRedirectUri => { let isValid = false; let isTrusted = false; let isAllowedProto = false; let isHttpsDowngrade = false; + let appUrlObj: URL; + + try { + appUrlObj = new URL(appUrl); + } catch { + return { + valid: isValid, + trusted: isTrusted, + allowedProto: isAllowedProto, + httpsDowngrade: isHttpsDowngrade, + }; + } + if (!redirect_uri) { return { valid: isValid, @@ -39,10 +54,7 @@ export const useRedirectUri = ( isValid = true; - if ( - url.hostname == cookieDomain || - url.hostname.endsWith(`.${cookieDomain}`) - ) { + if (isTrustedDomain(url, appUrlObj, cookieDomain, subdomainsEnabled)) { isTrusted = true; } @@ -62,3 +74,45 @@ export const useRedirectUri = ( httpsDowngrade: isHttpsDowngrade, }; }; + +// ported from internal/controller/oauth_controller.go +const getEffectivePort = (url: URL): string => { + if (url.port) { + return url.port; + } + + if (url.protocol == "https:") { + return "443"; + } + + return "80"; +}; + +export const isTrustedDomain = ( + url: URL, + appUrl: URL, + cookieDomain: string, + subdomainsEnabled: boolean, +): boolean => { + if (url.protocol != appUrl.protocol) { + return false; + } + + if (getEffectivePort(url) != getEffectivePort(appUrl)) { + return false; + } + + if (url.hostname == appUrl.hostname) { + return true; + } + + if (!subdomainsEnabled) { + return false; + } + + if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) { + return true; + } + + return false; +}; diff --git a/frontend/src/lib/hooks/screen-params.ts b/frontend/src/lib/hooks/screen-params.ts new file mode 100644 index 00000000..abf3a41a --- /dev/null +++ b/frontend/src/lib/hooks/screen-params.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +type ScreenParams = { + login_for?: "oidc" | "app"; + redirect_uri?: string; + oidc_ticket?: string; + oidc_scope?: string; + oidc_name?: string; + oidc_prompt?: "none" | "login"; +}; + +const zodScreenParams = z.object({ + login_for: z.enum(["oidc", "app"]).optional(), + redirect_uri: z.string().optional(), + oidc_ticket: z.string().optional(), + oidc_scope: z.string().optional(), + oidc_name: z.string().optional(), + oidc_prompt: z.enum(["none", "login"]).optional(), +}); + +export function useScreenParams(params: URLSearchParams): ScreenParams { + const paramsObj = Object.fromEntries(params.entries()); + const parsed = zodScreenParams.safeParse(paramsObj); + if (!parsed.success) { + return {}; + } + return parsed.data; +} + +export function recompileScreenParams(params: ScreenParams): string { + const p = new URLSearchParams( + Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined), + ) as Record, + ).toString(); + + if (p.length > 0) { + return "?" + p; + } + + return ""; +} diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index a71696e2..9eb3dc70 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -1,96 +1,106 @@ { - "loginTitle": "Welcome back, login with", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", - "loginUsername": "Username", - "loginPassword": "Password", - "loginSubmit": "Login", - "loginFailTitle": "Failed to log in", - "loginFailSubtitle": "Please check your username and password", - "loginFailRateLimit": "You failed to login too many times. Please try again later", - "loginSuccessTitle": "Logged in", - "loginSuccessSubtitle": "Welcome back!", - "loginOauthFailTitle": "An error occurred", - "loginOauthFailSubtitle": "Failed to get OAuth URL", - "loginOauthSuccessTitle": "Redirecting", - "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", - "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", - "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", - "loginOauthAutoRedirectButton": "Redirect now", - "continueTitle": "Continue", - "continueRedirectingTitle": "Redirecting...", - "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueRedirectManually": "Redirect me manually", - "continueInsecureRedirectTitle": "Insecure redirect", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueUntrustedRedirectTitle": "Untrusted redirect", - "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", - "logoutFailTitle": "Failed to log out", - "logoutFailSubtitle": "Please try again", - "logoutSuccessTitle": "Logged out", - "logoutSuccessSubtitle": "You have been logged out", - "logoutTitle": "Logout", - "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", - "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", - "notFoundTitle": "Page not found", - "notFoundSubtitle": "The page you are looking for does not exist.", - "notFoundButton": "Go home", - "totpFailTitle": "Failed to verify code", - "totpFailSubtitle": "Please check your code and try again", - "totpSuccessTitle": "Verified", - "totpSuccessSubtitle": "Redirecting to your app", - "totpTitle": "Enter your TOTP code", - "totpSubtitle": "Please enter the code from your authenticator app.", - "unauthorizedTitle": "Unauthorized", - "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", - "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", - "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", - "unauthorizedButton": "Try again", - "cancelTitle": "Cancel", - "forgotPasswordTitle": "Forgot your password?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", - "errorTitle": "An error occurred", - "errorSubtitleInfo": "The following error occurred while processing your request:", - "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input", - "domainWarningTitle": "Invalid Domain", - "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", - "domainWarningCurrent": "Current:", - "domainWarningExpected": "Expected:", - "ignoreTitle": "Ignore", - "goToCorrectDomainTitle": "Go to correct domain", - "authorizeTitle": "Authorize", - "authorizeCardTitle": "Continue to {{app}}?", - "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", - "authorizeSubtitleOAuth": "Would you like to continue to this app?", - "authorizeLoadingTitle": "Loading...", - "authorizeLoadingSubtitle": "Please wait while we load the client information.", - "authorizeSuccessTitle": "Authorized", - "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", - "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", - "authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}", - "openidScopeName": "OpenID Connect", - "openidScopeDescription": "Allows the app to access your OpenID Connect information.", - "emailScopeName": "Email", - "emailScopeDescription": "Allows the app to access your email address.", - "profileScopeName": "Profile", - "profileScopeDescription": "Allows the app to access your profile information.", - "groupsScopeName": "Groups", - "groupsScopeDescription": "Allows the app to access your group information.", - "backToLoginButton": "Back to login", - "phoneScopeName": "Phone", - "phoneScopeDescription": "Allows the app to access your phone number.", - "addressScopeName": "Address", - "addressScopeDescription": "Allows the app to access your address.", - "loginTailscaleTitle": "Continue with Tailscale", - "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", - "loginTailscaleDeviceName": "Device name:", - "loginTailscaleSubmit": "Continue with Tailscale", - "loginTailscaleOtherMethod": "Login with another method", - "loginTailscaleSuccess": "Successfully authenticated with Tailscale.", - "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", - "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device {{deviceName}}. Click the button below to logout." + "loginTitle": "Welcome back, login with", + "loginTitleSimple": "Welcome back, please login", + "loginDivider": "Or", + "loginUsername": "Username", + "loginPassword": "Password", + "loginSubmit": "Login", + "loginFailTitle": "Failed to log in", + "loginFailSubtitle": "Please check your username and password", + "loginFailRateLimit": "You failed to login too many times. Please try again later", + "loginSuccessTitle": "Logged in", + "loginSuccessSubtitle": "Welcome back!", + "loginOauthFailTitle": "An error occurred", + "loginOauthFailSubtitle": "Failed to get OAuth URL", + "loginOauthSuccessTitle": "Redirecting", + "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", + "continueRedirectingTitle": "Redirecting...", + "continueRedirectingSubtitle": "You should be redirected to the app soon", + "continueRedirectManually": "Redirect me manually", + "continueInsecureRedirectTitle": "Insecure redirect", + "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", + "logoutFailTitle": "Failed to log out", + "logoutFailSubtitle": "Please try again", + "logoutSuccessTitle": "Logged out", + "logoutSuccessSubtitle": "You have been logged out", + "logoutTitle": "Logout", + "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", + "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", + "notFoundTitle": "Page not found", + "notFoundSubtitle": "The page you are looking for does not exist.", + "notFoundButton": "Go home", + "totpFailTitle": "Failed to verify code", + "totpFailSubtitle": "Please check your code and try again", + "totpSuccessTitle": "Verified", + "totpSuccessSubtitle": "Redirecting to your app", + "totpTitle": "Enter your TOTP code", + "totpSubtitle": "Please enter the code from your authenticator app.", + "unauthorizedTitle": "Unauthorized", + "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", + "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", + "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", + "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", + "unauthorizedButton": "Try again", + "cancelTitle": "Cancel", + "forgotPasswordTitle": "Forgot your password?", + "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", + "errorTitle": "An error occurred", + "errorSubtitleInfo": "The following error occurred while processing your request:", + "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", + "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", + "fieldRequired": "This field is required", + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", + "domainWarningCurrent": "Current:", + "domainWarningExpected": "Expected:", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain", + "authorizeTitle": "Authorize", + "authorizeCardTitle": "Continue to {{app}}?", + "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", + "authorizeSubtitleOAuth": "Would you like to continue to this app?", + "authorizeLoadingTitle": "Loading...", + "authorizeLoadingSubtitle": "Please wait while we load the client information.", + "authorizeSuccessTitle": "Authorized", + "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", + "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", + "authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.", + "openidScopeName": "OpenID Connect", + "openidScopeDescription": "Allows the app to access your OpenID Connect information.", + "emailScopeName": "Email", + "emailScopeDescription": "Allows the app to access your email address.", + "profileScopeName": "Profile", + "profileScopeDescription": "Allows the app to access your profile information.", + "groupsScopeName": "Groups", + "groupsScopeDescription": "Allows the app to access your group information.", + "backToLoginButton": "Back to login", + "phoneScopeName": "Phone", + "phoneScopeDescription": "Allows the app to access your phone number.", + "addressScopeName": "Address", + "addressScopeDescription": "Allows the app to access your address.", + "loginTailscaleTitle": "Continue with Tailscale", + "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", + "loginTailscaleDeviceName": "Device name:", + "loginTailscaleSubmit": "Continue with Tailscale", + "loginTailscaleOtherMethod": "Login with another method", + "loginTailscaleSuccess": "Successfully authenticated with Tailscale.", + "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", + "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device {{deviceName}}. Click the button below to logout.", + "quickActionsLanguage": "Language", + "quickActionsTheme": "Theme", + "quickActionsThemeLight": "Light", + "quickActionsThemeDark": "Dark", + "quickActionsThemeSystem": "System", + "quickActionsLogout": "Logout", + "quickActionsTitle": "Quick Actions", + "quickActionsProviderLocal": "Local", + "quickActionsProviderLDAP": "LDAP", + "quickActionsProviderOAuth": "{{provider}} OAuth" } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a71696e2..9eb3dc70 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -1,96 +1,106 @@ { - "loginTitle": "Welcome back, login with", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", - "loginUsername": "Username", - "loginPassword": "Password", - "loginSubmit": "Login", - "loginFailTitle": "Failed to log in", - "loginFailSubtitle": "Please check your username and password", - "loginFailRateLimit": "You failed to login too many times. Please try again later", - "loginSuccessTitle": "Logged in", - "loginSuccessSubtitle": "Welcome back!", - "loginOauthFailTitle": "An error occurred", - "loginOauthFailSubtitle": "Failed to get OAuth URL", - "loginOauthSuccessTitle": "Redirecting", - "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", - "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", - "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", - "loginOauthAutoRedirectButton": "Redirect now", - "continueTitle": "Continue", - "continueRedirectingTitle": "Redirecting...", - "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueRedirectManually": "Redirect me manually", - "continueInsecureRedirectTitle": "Insecure redirect", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueUntrustedRedirectTitle": "Untrusted redirect", - "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", - "logoutFailTitle": "Failed to log out", - "logoutFailSubtitle": "Please try again", - "logoutSuccessTitle": "Logged out", - "logoutSuccessSubtitle": "You have been logged out", - "logoutTitle": "Logout", - "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", - "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", - "notFoundTitle": "Page not found", - "notFoundSubtitle": "The page you are looking for does not exist.", - "notFoundButton": "Go home", - "totpFailTitle": "Failed to verify code", - "totpFailSubtitle": "Please check your code and try again", - "totpSuccessTitle": "Verified", - "totpSuccessSubtitle": "Redirecting to your app", - "totpTitle": "Enter your TOTP code", - "totpSubtitle": "Please enter the code from your authenticator app.", - "unauthorizedTitle": "Unauthorized", - "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", - "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", - "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", - "unauthorizedButton": "Try again", - "cancelTitle": "Cancel", - "forgotPasswordTitle": "Forgot your password?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", - "errorTitle": "An error occurred", - "errorSubtitleInfo": "The following error occurred while processing your request:", - "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input", - "domainWarningTitle": "Invalid Domain", - "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", - "domainWarningCurrent": "Current:", - "domainWarningExpected": "Expected:", - "ignoreTitle": "Ignore", - "goToCorrectDomainTitle": "Go to correct domain", - "authorizeTitle": "Authorize", - "authorizeCardTitle": "Continue to {{app}}?", - "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", - "authorizeSubtitleOAuth": "Would you like to continue to this app?", - "authorizeLoadingTitle": "Loading...", - "authorizeLoadingSubtitle": "Please wait while we load the client information.", - "authorizeSuccessTitle": "Authorized", - "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", - "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", - "authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}", - "openidScopeName": "OpenID Connect", - "openidScopeDescription": "Allows the app to access your OpenID Connect information.", - "emailScopeName": "Email", - "emailScopeDescription": "Allows the app to access your email address.", - "profileScopeName": "Profile", - "profileScopeDescription": "Allows the app to access your profile information.", - "groupsScopeName": "Groups", - "groupsScopeDescription": "Allows the app to access your group information.", - "backToLoginButton": "Back to login", - "phoneScopeName": "Phone", - "phoneScopeDescription": "Allows the app to access your phone number.", - "addressScopeName": "Address", - "addressScopeDescription": "Allows the app to access your address.", - "loginTailscaleTitle": "Continue with Tailscale", - "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", - "loginTailscaleDeviceName": "Device name:", - "loginTailscaleSubmit": "Continue with Tailscale", - "loginTailscaleOtherMethod": "Login with another method", - "loginTailscaleSuccess": "Successfully authenticated with Tailscale.", - "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", - "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device {{deviceName}}. Click the button below to logout." + "loginTitle": "Welcome back, login with", + "loginTitleSimple": "Welcome back, please login", + "loginDivider": "Or", + "loginUsername": "Username", + "loginPassword": "Password", + "loginSubmit": "Login", + "loginFailTitle": "Failed to log in", + "loginFailSubtitle": "Please check your username and password", + "loginFailRateLimit": "You failed to login too many times. Please try again later", + "loginSuccessTitle": "Logged in", + "loginSuccessSubtitle": "Welcome back!", + "loginOauthFailTitle": "An error occurred", + "loginOauthFailSubtitle": "Failed to get OAuth URL", + "loginOauthSuccessTitle": "Redirecting", + "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", + "continueRedirectingTitle": "Redirecting...", + "continueRedirectingSubtitle": "You should be redirected to the app soon", + "continueRedirectManually": "Redirect me manually", + "continueInsecureRedirectTitle": "Insecure redirect", + "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", + "logoutFailTitle": "Failed to log out", + "logoutFailSubtitle": "Please try again", + "logoutSuccessTitle": "Logged out", + "logoutSuccessSubtitle": "You have been logged out", + "logoutTitle": "Logout", + "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", + "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", + "notFoundTitle": "Page not found", + "notFoundSubtitle": "The page you are looking for does not exist.", + "notFoundButton": "Go home", + "totpFailTitle": "Failed to verify code", + "totpFailSubtitle": "Please check your code and try again", + "totpSuccessTitle": "Verified", + "totpSuccessSubtitle": "Redirecting to your app", + "totpTitle": "Enter your TOTP code", + "totpSubtitle": "Please enter the code from your authenticator app.", + "unauthorizedTitle": "Unauthorized", + "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", + "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", + "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", + "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", + "unauthorizedButton": "Try again", + "cancelTitle": "Cancel", + "forgotPasswordTitle": "Forgot your password?", + "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", + "errorTitle": "An error occurred", + "errorSubtitleInfo": "The following error occurred while processing your request:", + "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", + "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", + "fieldRequired": "This field is required", + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", + "domainWarningCurrent": "Current:", + "domainWarningExpected": "Expected:", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain", + "authorizeTitle": "Authorize", + "authorizeCardTitle": "Continue to {{app}}?", + "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", + "authorizeSubtitleOAuth": "Would you like to continue to this app?", + "authorizeLoadingTitle": "Loading...", + "authorizeLoadingSubtitle": "Please wait while we load the client information.", + "authorizeSuccessTitle": "Authorized", + "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", + "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", + "authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.", + "openidScopeName": "OpenID Connect", + "openidScopeDescription": "Allows the app to access your OpenID Connect information.", + "emailScopeName": "Email", + "emailScopeDescription": "Allows the app to access your email address.", + "profileScopeName": "Profile", + "profileScopeDescription": "Allows the app to access your profile information.", + "groupsScopeName": "Groups", + "groupsScopeDescription": "Allows the app to access your group information.", + "backToLoginButton": "Back to login", + "phoneScopeName": "Phone", + "phoneScopeDescription": "Allows the app to access your phone number.", + "addressScopeName": "Address", + "addressScopeDescription": "Allows the app to access your address.", + "loginTailscaleTitle": "Continue with Tailscale", + "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", + "loginTailscaleDeviceName": "Device name:", + "loginTailscaleSubmit": "Continue with Tailscale", + "loginTailscaleOtherMethod": "Login with another method", + "loginTailscaleSuccess": "Successfully authenticated with Tailscale.", + "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", + "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device {{deviceName}}. Click the button below to logout.", + "quickActionsLanguage": "Language", + "quickActionsTheme": "Theme", + "quickActionsThemeLight": "Light", + "quickActionsThemeDark": "Dark", + "quickActionsThemeSystem": "System", + "quickActionsLogout": "Logout", + "quickActionsTitle": "Quick Actions", + "quickActionsProviderLocal": "Local", + "quickActionsProviderLDAP": "LDAP", + "quickActionsProviderOAuth": "{{provider}} OAuth" } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 29b3e475..4af686d5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -35,7 +35,10 @@ createRoot(document.getElementById("root")!).render( } errorElement={}> } /> } /> - } /> + } + /> } /> } /> } /> diff --git a/frontend/src/pages/authorize-page.tsx b/frontend/src/pages/authorize-page.tsx index 91f8f9c9..0f14a583 100644 --- a/frontend/src/pages/authorize-page.tsx +++ b/frontend/src/pages/authorize-page.tsx @@ -1,5 +1,5 @@ import { useUserContext } from "@/context/user-context"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Navigate, useNavigate } from "react-router"; import { useLocation } from "react-router"; import { @@ -10,11 +10,9 @@ import { CardFooter, CardContent, } from "@/components/ui/card"; -import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas"; import { Button } from "@/components/ui/button"; import axios from "axios"; import { toast } from "sonner"; -import { useOIDCParams } from "@/lib/hooks/oidc"; import { useTranslation } from "react-i18next"; import { TFunction } from "i18next"; import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react"; @@ -23,6 +21,11 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + recompileScreenParams, + useScreenParams, +} from "@/lib/hooks/screen-params"; +import { useEffect } from "react"; type Scope = { id: string; @@ -84,27 +87,25 @@ export const AuthorizePage = () => { const scopeMap = createScopeMap(t); const searchParams = new URLSearchParams(search); - const oidcParams = useOIDCParams(searchParams); + const screenParams = useScreenParams(searchParams); + const isOidc = screenParams.login_for === "oidc"; + const compiledParams = recompileScreenParams(screenParams); - const getClientInfo = useQuery({ - queryKey: ["client", oidcParams.values.client_id], - queryFn: async () => { - const res = await fetch( - `/api/oidc/clients/${encodeURIComponent(oidcParams.values.client_id)}`, - ); - const data = await getOidcClientInfoSchema.parseAsync(await res.json()); - return data; - }, - enabled: oidcParams.isOidc, - }); + // TODO: maybe a better way to do this + const shouldAutoAuthorize = + auth.authenticated && + isOidc && + screenParams.oidc_ticket !== undefined && + screenParams.oidc_scope !== undefined && + screenParams.oidc_prompt === "none"; - const authorizeMutation = useMutation({ + const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({ mutationFn: () => { - return axios.post("/api/oidc/authorize", { - ...oidcParams.values, + return axios.post("/api/oidc/authorize-complete", { + ticket: screenParams.oidc_ticket, }); }, - mutationKey: ["authorize", oidcParams.values.client_id], + mutationKey: ["authorize", screenParams.oidc_ticket], onSuccess: (data) => { toast.info(t("authorizeSuccessTitle"), { description: t("authorizeSuccessSubtitle"), @@ -118,56 +119,38 @@ export const AuthorizePage = () => { }, }); - if (oidcParams.issues.length > 0) { + useEffect(() => { + if (shouldAutoAuthorize) { + authorizeMutate(); + } + }, [shouldAutoAuthorize, authorizeMutate]); + + if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) { return ( ); } - if (!auth.authenticated) { - return ; - } - - if (getClientInfo.isLoading) { - return ( - - - - {t("authorizeLoadingTitle")} - - - - {t("authorizeLoadingSubtitle")} - - - ); - } - - if (getClientInfo.isError) { - return ( - - ); + if (!auth.authenticated || screenParams.oidc_prompt === "login") { + return ; } const scopes = - oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || []; + screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || []; return (
- {getClientInfo.data?.name.slice(0, 1) || "U"} + {screenParams.oidc_name ? screenParams.oidc_name.slice(0, 1) : "U"}
{t("authorizeCardTitle", { - app: getClientInfo.data?.name || "Unknown", + app: screenParams.oidc_name || "Unknown", })} @@ -200,14 +183,15 @@ export const AuthorizePage = () => { )}