mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-31 15:06:42 +00:00
Compare commits
31 Commits
1.16.2-s.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e345c6ee6e | ||
|
|
5cad07f8ad | ||
|
|
f9d872558e | ||
|
|
48013228c1 | ||
|
|
dbafffe73d | ||
|
|
61cbcb2a06 | ||
|
|
89c1ad5d98 | ||
|
|
b343ca6290 | ||
|
|
b913466671 | ||
|
|
9054f4f9c3 | ||
|
|
3915024d9a | ||
|
|
7d1085b43f | ||
|
|
7c2477cccc | ||
|
|
5aecb5fb90 | ||
|
|
f86d040ee4 | ||
|
|
ed32717b3f | ||
|
|
aab8462134 | ||
|
|
c20dfdabfb | ||
|
|
11a6f1f47f | ||
|
|
8e160902af | ||
|
|
06f840a680 | ||
|
|
5ddcfeb506 | ||
|
|
914e95e47f | ||
|
|
5b9efc3c5f | ||
|
|
871f14ef3a | ||
|
|
1d5dfd6db2 | ||
|
|
ad3fe2fa76 | ||
|
|
863eb8efe9 | ||
|
|
5455d1c118 | ||
|
|
ae39084a75 | ||
|
|
27d20eb1bc |
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
@@ -415,7 +415,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign and verify container images (key and keyless)
|
# cosign is used to sign and verify container images (key and keyless)
|
||||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||||
|
|
||||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||||
|
|||||||
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -99,11 +99,6 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
|
|||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findPattern finds the start of a pattern in a string
|
|
||||||
func findPattern(s, pattern string) int {
|
|
||||||
return bytes.Index([]byte(s), []byte(pattern))
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||||
// Read source file
|
// Read source file
|
||||||
sourceData, err := os.ReadFile(sourceFile)
|
sourceData, err := os.ReadFile(sourceFile)
|
||||||
@@ -187,7 +182,7 @@ func backupConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
|
func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
|
||||||
buffer := new(bytes.Buffer)
|
buffer := new(bytes.Buffer)
|
||||||
encoder := yaml.NewEncoder(buffer)
|
encoder := yaml.NewEncoder(buffer)
|
||||||
encoder.SetIndent(indent)
|
encoder.SetIndent(indent)
|
||||||
@@ -196,7 +191,12 @@ func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer encoder.Close()
|
defer func() {
|
||||||
|
if cerr := encoder.Close(); cerr != nil && err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return buffer.Bytes(), nil
|
return buffer.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,17 @@ entryPoints:
|
|||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: "30m"
|
||||||
|
http3:
|
||||||
|
advertisedPort: 443
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
middlewares:
|
encodedCharacters:
|
||||||
- crowdsec@file
|
allowEncodedSlash: true
|
||||||
|
allowEncodedQuestionMark: true
|
||||||
|
|
||||||
serversTransport:
|
serversTransport:
|
||||||
insecureSkipVerify: true
|
insecureSkipVerify: true
|
||||||
|
|
||||||
|
ping:
|
||||||
|
entryPoint: "web"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80
|
- 80:80
|
||||||
{{end}}
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ entryPoints:
|
|||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: "30m"
|
||||||
|
http3:
|
||||||
|
advertisedPort: 443
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module installer
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v0.8.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs
|
|||||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||||
|
|||||||
@@ -85,33 +85,6 @@ func readString(prompt string, defaultValue string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readStringNoDefault(prompt string) string {
|
|
||||||
var value string
|
|
||||||
|
|
||||||
for {
|
|
||||||
input := huh.NewInput().
|
|
||||||
Title(prompt).
|
|
||||||
Value(&value).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if s == "" {
|
|
||||||
return fmt.Errorf("this field is required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runField(input)
|
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
if value != "" {
|
|
||||||
// Print the answer so it remains visible in terminal history
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %s\n", prompt, value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readPassword(prompt string) string {
|
func readPassword(prompt string) string {
|
||||||
var value string
|
var value string
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -430,9 +429,9 @@ func createConfigFiles(config Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Walk through all embedded files
|
// Walk through all embedded files
|
||||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
|
||||||
if err != nil {
|
if walkErr != nil {
|
||||||
return err
|
return walkErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the root fs directory itself
|
// Skip the root fs directory itself
|
||||||
@@ -483,7 +482,11 @@ func createConfigFiles(config Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer func() {
|
||||||
|
if cerr := outFile.Close(); cerr != nil && err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Execute template
|
// Execute template
|
||||||
if err := tmpl.Execute(outFile, config); err != nil {
|
if err := tmpl.Execute(outFile, config); err != nil {
|
||||||
@@ -499,18 +502,26 @@ func createConfigFiles(config Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) (err error) {
|
||||||
source, err := os.Open(src)
|
source, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer source.Close()
|
defer func() {
|
||||||
|
if cerr := source.Close(); cerr != nil && err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
destination, err := os.Create(dst)
|
destination, err := os.Create(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer destination.Close()
|
defer func() {
|
||||||
|
if cerr := destination.Close(); cerr != nil && err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
_, err = io.Copy(destination, source)
|
_, err = io.Copy(destination, source)
|
||||||
return err
|
return err
|
||||||
@@ -622,32 +633,6 @@ func generateRandomSecretKey() string {
|
|||||||
return base64.StdEncoding.EncodeToString(secret)
|
return base64.StdEncoding.EncodeToString(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicIP() string {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Get("https://ifconfig.io/ip")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := strings.TrimSpace(string(body))
|
|
||||||
|
|
||||||
// Validate that it's a valid IP address
|
|
||||||
if net.ParseIP(ip) != nil {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run external commands with stdio/stderr attached.
|
// Run external commands with stdio/stderr attached.
|
||||||
func run(name string, args ...string) error {
|
func run(name string, args ...string) error {
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
|
|||||||
641
package-lock.json
generated
641
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -92,12 +92,12 @@
|
|||||||
"lucide-react": "0.577.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.12",
|
"next": "15.5.14",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.8.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.4",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.28.0",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.19.0",
|
||||||
"yaml": "2.8.2",
|
"yaml": "2.8.3",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.3.6",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.54.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/preview-server": "5.2.10",
|
||||||
"@tailwindcss/postcss": "4.2.1",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -160,21 +160,21 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.27.4",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.20.1",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.0.3",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.8",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"react-email": "5.2.10",
|
"react-email": "5.2.10",
|
||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "4.2.2",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.56.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.27.4",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ export const users = pgTable("user", {
|
|||||||
termsVersion: varchar("termsVersion"),
|
termsVersion: varchar("termsVersion"),
|
||||||
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
||||||
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
||||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }),
|
||||||
|
locale: varchar("locale")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newts = pgTable("newt", {
|
export const newts = pgTable("newt", {
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ export const users = sqliteTable("user", {
|
|||||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
lastPasswordChange: integer("lastPasswordChange")
|
lastPasswordChange: integer("lastPasswordChange"),
|
||||||
|
locale: text("locale")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||||
|
|||||||
@@ -793,6 +793,11 @@ unauthenticated.get(
|
|||||||
// );
|
// );
|
||||||
|
|
||||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||||
|
unauthenticated.post(
|
||||||
|
"/user/locale",
|
||||||
|
verifySessionMiddleware,
|
||||||
|
user.updateUserLocale
|
||||||
|
);
|
||||||
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
|
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
|
||||||
|
|
||||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ async function queryUser(userId: string) {
|
|||||||
emailVerified: users.emailVerified,
|
emailVerified: users.emailVerified,
|
||||||
serverAdmin: users.serverAdmin,
|
serverAdmin: users.serverAdmin,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId,
|
||||||
|
locale: users.locale
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export * from "./createOrgUser";
|
|||||||
export * from "./adminUpdateUser2FA";
|
export * from "./adminUpdateUser2FA";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
export * from "./updateOrgUser";
|
export * from "./updateOrgUser";
|
||||||
|
export * from "./updateUserLocale";
|
||||||
export * from "./myDevice";
|
export * from "./myDevice";
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export async function myDevice(
|
|||||||
emailVerified: users.emailVerified,
|
emailVerified: users.emailVerified,
|
||||||
serverAdmin: users.serverAdmin,
|
serverAdmin: users.serverAdmin,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId,
|
||||||
|
locale: users.locale
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
|||||||
57
server/routers/user/updateUserLocale.ts
Normal file
57
server/routers/user/updateUserLocale.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
locale: z.string().min(2).max(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateUserLocale(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = parsedBody.data;
|
||||||
|
|
||||||
|
await db.update(users).set({ locale }).where(eq(users.userId, userId));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "User locale updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import clsx from "clsx";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Locale } from "@/i18n/config";
|
import { Locale } from "@/i18n/config";
|
||||||
import { setUserLocale } from "@/services/locale";
|
import { setUserLocale } from "@/services/locale";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
@@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({
|
|||||||
label
|
label
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
function onChange(value: string) {
|
function onChange(value: string) {
|
||||||
const locale = value as Locale;
|
const locale = value as Locale;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setUserLocale(locale);
|
setUserLocale(locale);
|
||||||
});
|
});
|
||||||
|
// Persist locale to the database (fire-and-forget)
|
||||||
|
api.post("/user/locale", { locale }).catch(() => {
|
||||||
|
// Silently ignore errors — cookie is already set as fallback
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = items.find((item) => item.value === defaultValue);
|
const selected = items.find((item) => item.value === defaultValue);
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { Locale, defaultLocale, locales } from "@/i18n/config";
|
import { Locale, defaultLocale, locales } from "@/i18n/config";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
|
||||||
// In this example the locale is read from a cookie. You could alternatively
|
// In this example the locale is read from a cookie. You could alternatively
|
||||||
// also read it from a database, backend service, or any other source.
|
// also read it from a database, backend service, or any other source.
|
||||||
const COOKIE_NAME = "NEXT_LOCALE";
|
const COOKIE_NAME = "NEXT_LOCALE";
|
||||||
|
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds
|
||||||
|
|
||||||
export async function getUserLocale(): Promise<Locale> {
|
export async function getUserLocale(): Promise<Locale> {
|
||||||
const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value;
|
const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value;
|
||||||
@@ -14,6 +17,23 @@ export async function getUserLocale(): Promise<Locale> {
|
|||||||
return cookieLocale as Locale;
|
return cookieLocale as Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cookie found — try to restore from user's saved locale in DB
|
||||||
|
try {
|
||||||
|
const res = await internal.get("/user", await authCookieHeader());
|
||||||
|
const userLocale = res.data?.data?.locale;
|
||||||
|
if (userLocale && locales.includes(userLocale as Locale)) {
|
||||||
|
// Set the cookie so subsequent requests don't need the API call
|
||||||
|
(await cookies()).set(COOKIE_NAME, userLocale, {
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax"
|
||||||
|
});
|
||||||
|
return userLocale as Locale;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// User not logged in or API unavailable — fall through
|
||||||
|
}
|
||||||
|
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
const acceptLang = headerList.get("accept-language");
|
const acceptLang = headerList.get("accept-language");
|
||||||
|
|
||||||
@@ -33,5 +53,9 @@ export async function getUserLocale(): Promise<Locale> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setUserLocale(locale: Locale) {
|
export async function setUserLocale(locale: Locale) {
|
||||||
(await cookies()).set(COOKIE_NAME, locale);
|
(await cookies()).set(COOKIE_NAME, locale, {
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user