@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +70 -0
- package/backend/cmd/server/main.go +17 -1
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -0
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -0
- package/backend/internal/auth/internal/store/session.go +63 -5
- package/backend/internal/auth/logging_integration_test.go +88 -0
- package/backend/internal/auth/login.go +252 -0
- package/backend/internal/auth/login_integration_test.go +421 -0
- package/backend/internal/auth/login_test.go +68 -0
- package/backend/internal/auth/logout.go +68 -0
- package/backend/internal/auth/logout_integration_test.go +124 -0
- package/backend/internal/auth/me.go +40 -0
- package/backend/internal/auth/me_integration_test.go +70 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +20 -1
- package/backend/internal/server/api.go +19 -0
- package/backend/internal/server/public.go +37 -0
- package/backend/internal/server/public_test.go +73 -0
- package/backend/internal/server/server.go +7 -3
- package/backend/internal/shared/config/config.go +23 -4
- package/backend/internal/shared/config/config_test.go +14 -8
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/index.css +102 -102
- package/frontend/src/app/layouts.tsx +75 -18
- package/frontend/src/app/pages/auth.tsx +52 -78
- package/frontend/src/app/pages/login.test.tsx +218 -0
- package/frontend/src/app/pages/register.test.tsx +231 -0
- package/frontend/src/app/router.tsx +83 -34
- package/frontend/src/app/session.ts +71 -18
- package/frontend/src/features/auth/LoginForm.tsx +145 -0
- package/frontend/src/features/auth/RegisterForm.tsx +211 -0
- package/frontend/src/features/auth/login.ts +23 -0
- package/frontend/src/features/auth/register.ts +105 -0
- package/frontend/src/shared/api/csrf.ts +51 -0
- package/frontend/src/shared/api/http.test.tsx +209 -0
- package/frontend/src/shared/api/http.ts +91 -0
- package/frontend/src/shared/api/index.ts +3 -0
- package/frontend/src/shared/i18n/locales/en.ts +15 -2
- package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
- package/frontend/src/shared/text/index.ts +20 -0
- package/frontend/src/shared/text/text.test.ts +28 -1
- package/frontend/vite.config.ts +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import "net/http"
|
|
4
|
+
|
|
5
|
+
// PublicRoutes is the explicit list of operations that need no session
|
|
6
|
+
// (Backend.md §3). It must equal the operations marked `security: []` in
|
|
7
|
+
// api/openapi.yaml; a test locks that. Paths include the /api/v1 prefix.
|
|
8
|
+
var PublicRoutes = []struct{ Method, Path string }{
|
|
9
|
+
{http.MethodGet, "/api/v1/auth/csrf"},
|
|
10
|
+
{http.MethodPost, "/api/v1/auth/login"},
|
|
11
|
+
{http.MethodPost, "/api/v1/auth/register"},
|
|
12
|
+
{http.MethodPost, "/api/v1/auth/logout"},
|
|
13
|
+
{http.MethodGet, "/api/v1/deadline"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// IsPublic reports whether r is exactly one of PublicRoutes. A public path with
|
|
17
|
+
// another method is not public: without a session it answers 401, with one 405.
|
|
18
|
+
func IsPublic(r *http.Request) bool {
|
|
19
|
+
for _, p := range PublicRoutes {
|
|
20
|
+
if r.Method == p.Method && r.URL.Path == p.Path {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// WriteActions maps the writes that exist today to their audit action, so a
|
|
28
|
+
// CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
|
|
29
|
+
// the same exact-path rule; a write on an unknown path is not audited.
|
|
30
|
+
var WriteActions = map[string]string{
|
|
31
|
+
http.MethodPost + " /api/v1/auth/login": "AUTH_LOGIN",
|
|
32
|
+
http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
|
|
33
|
+
http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ActionFor returns the audit action of r, or "" when unknown.
|
|
37
|
+
func ActionFor(r *http.Request) string { return WriteActions[r.Method+" "+r.URL.Path] }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"slices"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/getkin/kin-openapi/openapi3"
|
|
11
|
+
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// The public list in code must equal the operations the spec marks
|
|
16
|
+
// `security: []`; otherwise the contract and the middleware disagree.
|
|
17
|
+
func TestPublicRoutesMatchOpenAPI(t *testing.T) {
|
|
18
|
+
doc, err := openapi3.NewLoader().LoadFromFile("../../api/openapi.yaml")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
var want []string
|
|
23
|
+
for path, item := range doc.Paths.Map() {
|
|
24
|
+
for method, op := range item.Operations() {
|
|
25
|
+
if op.Security != nil && len(*op.Security) == 0 {
|
|
26
|
+
want = append(want, method+" /api/v1"+path)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
var got []string
|
|
31
|
+
for _, p := range server.PublicRoutes {
|
|
32
|
+
got = append(got, p.Method+" "+p.Path)
|
|
33
|
+
}
|
|
34
|
+
slices.Sort(want)
|
|
35
|
+
slices.Sort(got)
|
|
36
|
+
if !slices.Equal(want, got) {
|
|
37
|
+
t.Errorf("PublicRoutes\n got %v\n want %v (spec, security: [])", got, want)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestIsPublicNeedsExactMethodAndPath(t *testing.T) {
|
|
42
|
+
tests := []struct {
|
|
43
|
+
method, path string
|
|
44
|
+
want bool
|
|
45
|
+
}{
|
|
46
|
+
{"GET", "/api/v1/auth/csrf", true},
|
|
47
|
+
{"POST", "/api/v1/auth/logout", true},
|
|
48
|
+
{"POST", "/api/v1/auth/csrf", false},
|
|
49
|
+
{"HEAD", "/api/v1/deadline", false},
|
|
50
|
+
{"GET", "/api/v1/auth/csrf/", false},
|
|
51
|
+
{"GET", "/api/v1/auth/me", false},
|
|
52
|
+
{"GET", "/api/v1/auth/../ideas", false},
|
|
53
|
+
{"GET", "/auth/csrf", false},
|
|
54
|
+
}
|
|
55
|
+
for _, tc := range tests {
|
|
56
|
+
req := httptest.NewRequestWithContext(context.Background(), tc.method, "http://x"+tc.path, nil)
|
|
57
|
+
if got := server.IsPublic(req); got != tc.want {
|
|
58
|
+
t.Errorf("%s %s = %v, want %v", tc.method, tc.path, got, tc.want)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestActionForCoversPublicWrites(t *testing.T) {
|
|
64
|
+
for _, p := range server.PublicRoutes {
|
|
65
|
+
if p.Method == http.MethodGet {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
r := httptest.NewRequest(p.Method, p.Path, nil)
|
|
69
|
+
if server.ActionFor(r) == "" {
|
|
70
|
+
t.Errorf("%s %s has no audit action for a CSRF denial", p.Method, p.Path)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -22,8 +22,8 @@ const (
|
|
|
22
22
|
|
|
23
23
|
// NewPublic builds the public router. Module routes are mounted by `mount`
|
|
24
24
|
// (nil in the skeleton) under the chain: request ID → access log → recover →
|
|
25
|
-
// Accept check.
|
|
26
|
-
func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
25
|
+
// extra (the session middleware, so 401 beats 406) → Accept check.
|
|
26
|
+
func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Handler) http.Handler) http.Handler {
|
|
27
27
|
r := chi.NewRouter()
|
|
28
28
|
r.NotFound(web.NotFound)
|
|
29
29
|
r.MethodNotAllowed(web.MethodNotAllowed)
|
|
@@ -32,7 +32,11 @@ func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
|
32
32
|
}
|
|
33
33
|
// The chain wraps the router instead of using r.Use: chi skips r.Use
|
|
34
34
|
// middleware (also for NotFound) while no route is registered.
|
|
35
|
-
|
|
35
|
+
h := web.AcceptCheck(r)
|
|
36
|
+
for i := len(extra) - 1; i >= 0; i-- {
|
|
37
|
+
h = extra[i](h)
|
|
38
|
+
}
|
|
39
|
+
return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// Check reports whether one dependency is usable; a non-nil error makes
|
|
@@ -18,8 +18,16 @@ type Config struct {
|
|
|
18
18
|
LogLevel slog.Level
|
|
19
19
|
ShutdownTimeout time.Duration // must stay below terminationGracePeriodSeconds
|
|
20
20
|
ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
|
|
21
|
+
CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
|
|
22
|
+
CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// DefaultCSRFKey is the throwaway key of the local profile only; every other
|
|
26
|
+
// profile must set ESG_CSRF_KEY.
|
|
27
|
+
const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
|
|
28
|
+
|
|
29
|
+
const minCSRFKeyBytes = 32
|
|
30
|
+
|
|
23
31
|
// DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
|
|
24
32
|
// pins UTC and matched-row counts (Backend.md §2.3).
|
|
25
33
|
const DefaultDSN = "esg:esg@tcp(localhost:3306)/esg?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
|
|
@@ -33,16 +41,27 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
33
41
|
return def
|
|
34
42
|
}
|
|
35
43
|
c := Config{
|
|
36
|
-
Profile:
|
|
37
|
-
PublicAddr:
|
|
38
|
-
ManagementAddr:
|
|
39
|
-
DBDSN:
|
|
44
|
+
Profile: get("ESG_PROFILE", "local"),
|
|
45
|
+
PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
|
|
46
|
+
ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
|
|
47
|
+
DBDSN: get("ESG_DB_DSN", DefaultDSN),
|
|
48
|
+
CSRFKey: getenv("ESG_CSRF_KEY"),
|
|
49
|
+
CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
|
|
40
50
|
}
|
|
41
51
|
switch c.Profile {
|
|
42
52
|
case "local", "k3s", "dev":
|
|
43
53
|
default:
|
|
44
54
|
return Config{}, fmt.Errorf("ESG_PROFILE %q: want local, k3s or dev", c.Profile)
|
|
45
55
|
}
|
|
56
|
+
if c.CSRFKey == "" && c.Profile == "local" {
|
|
57
|
+
c.CSRFKey = DefaultCSRFKey
|
|
58
|
+
}
|
|
59
|
+
if len(c.CSRFKey) < minCSRFKeyBytes {
|
|
60
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
|
|
61
|
+
}
|
|
62
|
+
if c.CSRFKeyPrevious != "" && len(c.CSRFKeyPrevious) < minCSRFKeyBytes {
|
|
63
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY_PREVIOUS: need at least %d bytes", minCSRFKeyBytes)
|
|
64
|
+
}
|
|
46
65
|
if err := c.LogLevel.UnmarshalText([]byte(strings.ToUpper(get("ESG_LOG_LEVEL", "info")))); err != nil {
|
|
47
66
|
return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
|
|
48
67
|
}
|
|
@@ -8,6 +8,8 @@ import (
|
|
|
8
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
const key = "0123456789abcdef0123456789abcdef"
|
|
12
|
+
|
|
11
13
|
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
|
12
14
|
|
|
13
15
|
func TestDefaults(t *testing.T) {
|
|
@@ -16,14 +18,14 @@ func TestDefaults(t *testing.T) {
|
|
|
16
18
|
t.Fatal(err)
|
|
17
19
|
}
|
|
18
20
|
if c.Profile != "local" || c.PublicAddr != ":8080" || c.ManagementAddr != ":8081" ||
|
|
19
|
-
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second {
|
|
21
|
+
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second || c.CSRFKey != config.DefaultCSRFKey {
|
|
20
22
|
t.Fatalf("%+v", c)
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
func TestOverrides(t *testing.T) {
|
|
25
27
|
c, err := config.Load(env(map[string]string{
|
|
26
|
-
"ESG_PROFILE": "dev", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
|
|
28
|
+
"ESG_PROFILE": "dev", "ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": key + "2", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
|
|
27
29
|
}))
|
|
28
30
|
if err != nil || c.Profile != "dev" || c.LogLevel != slog.LevelDebug || c.ReadyDrainDelay != time.Second {
|
|
29
31
|
t.Fatalf("%+v %v", c, err)
|
|
@@ -32,12 +34,16 @@ func TestOverrides(t *testing.T) {
|
|
|
32
34
|
|
|
33
35
|
func TestInvalid(t *testing.T) {
|
|
34
36
|
for name, m := range map[string]map[string]string{
|
|
35
|
-
"profile":
|
|
36
|
-
"log level":
|
|
37
|
-
"timeout":
|
|
38
|
-
"zero timeout":
|
|
39
|
-
"drain":
|
|
40
|
-
"
|
|
37
|
+
"profile": {"ESG_PROFILE": "prod"},
|
|
38
|
+
"log level": {"ESG_LOG_LEVEL": "loud"},
|
|
39
|
+
"timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
|
|
40
|
+
"zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
|
|
41
|
+
"drain": {"ESG_READY_DRAIN_DELAY": "x"},
|
|
42
|
+
"short csrf key": {"ESG_CSRF_KEY": "short"},
|
|
43
|
+
"short previous key": {"ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": "short"},
|
|
44
|
+
"dev needs csrf key": {"ESG_PROFILE": "dev"},
|
|
45
|
+
"k3s needs csrf key": {"ESG_PROFILE": "k3s"},
|
|
46
|
+
"same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
|
|
41
47
|
} {
|
|
42
48
|
if _, err := config.Load(env(m)); err == nil {
|
|
43
49
|
t.Errorf("%s: want error", name)
|
|
@@ -3,6 +3,8 @@ package web
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"net/http"
|
|
6
|
+
"strconv"
|
|
7
|
+
"time"
|
|
6
8
|
|
|
7
9
|
"github.com/google/uuid"
|
|
8
10
|
)
|
|
@@ -28,6 +30,9 @@ type Error struct {
|
|
|
28
30
|
Detail string
|
|
29
31
|
Fields []FieldError
|
|
30
32
|
Cause error
|
|
33
|
+
// RetryAfter, when positive, is sent as Retry-After in whole seconds,
|
|
34
|
+
// rounded up (minimum 1; PRD §6.5 throttling).
|
|
35
|
+
RetryAfter time.Duration
|
|
31
36
|
|
|
32
37
|
// AuditReason is the real reason of a rejected attempt (CSRF, role, state,
|
|
33
38
|
// not-yours, not-found). It is stored in the row's after_json, never sent
|
|
@@ -94,6 +99,10 @@ func WriteProblem(w http.ResponseWriter, r *http.Request, e *Error) {
|
|
|
94
99
|
}
|
|
95
100
|
w.Header().Set("Content-Type", ProblemContentType)
|
|
96
101
|
w.Header().Set("Cache-Control", "no-store")
|
|
102
|
+
if e.RetryAfter > 0 {
|
|
103
|
+
secs := max(1, int64((e.RetryAfter+time.Second-1)/time.Second))
|
|
104
|
+
w.Header().Set("Retry-After", strconv.FormatInt(secs, 10))
|
|
105
|
+
}
|
|
97
106
|
w.WriteHeader(e.Status)
|
|
98
107
|
_ = json.NewEncoder(w).Encode(body)
|
|
99
108
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package web_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http/httptest"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestRetryAfterWholeSecondsRoundedUpMinimumOne(t *testing.T) {
|
|
12
|
+
tests := []struct {
|
|
13
|
+
d time.Duration
|
|
14
|
+
want string
|
|
15
|
+
}{
|
|
16
|
+
{0, ""},
|
|
17
|
+
{time.Millisecond, "1"},
|
|
18
|
+
{time.Second, "1"},
|
|
19
|
+
{1001 * time.Millisecond, "2"},
|
|
20
|
+
{15 * time.Minute, "900"},
|
|
21
|
+
}
|
|
22
|
+
for _, tc := range tests {
|
|
23
|
+
e := web.NewError(web.CodeLoginThrottled, "")
|
|
24
|
+
e.RetryAfter = tc.d
|
|
25
|
+
rec := httptest.NewRecorder()
|
|
26
|
+
web.WriteProblem(rec, httptest.NewRequest("POST", "/", nil), e)
|
|
27
|
+
if got := rec.Header().Get("Retry-After"); got != tc.want {
|
|
28
|
+
t.Errorf("%v: Retry-After = %q, want %q", tc.d, got, tc.want)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -161,3 +161,16 @@ describe('accessibility', () => {
|
|
|
161
161
|
expect((await axe(container)).violations).toEqual([]);
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
describe('global 401 (Frontend.md §5.2)', () => {
|
|
166
|
+
it('goes to /login?next=<current> with a session-ended notice and forgets the user', async () => {
|
|
167
|
+
const { router } = await open('/ideas?page=2', 'USER');
|
|
168
|
+
await screen.findByRole('heading', { level: 1 });
|
|
169
|
+
server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
|
|
170
|
+
const { apiFetch } = await import('@/shared/api');
|
|
171
|
+
await expect(apiFetch('/ideas')).rejects.toMatchObject({ status: 401 });
|
|
172
|
+
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
|
173
|
+
expect(router.state.location.search).toBe(`?next=${encodeURIComponent('/ideas?page=2')}`);
|
|
174
|
+
expect(router.state.location.state).toEqual({ notice: 'sessionEnded' });
|
|
175
|
+
});
|
|
176
|
+
});
|
package/frontend/src/app/App.tsx
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
1
2
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
3
|
import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
|
|
4
|
+
import { setUnauthenticatedHandler } from '@/shared/api';
|
|
3
5
|
import { routes } from './router';
|
|
6
|
+
import { handleSessionEnded } from './session';
|
|
4
7
|
|
|
5
8
|
const queryClient = new QueryClient();
|
|
6
9
|
const browserRouter = createBrowserRouter(routes);
|
|
7
10
|
|
|
8
11
|
export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
|
|
12
|
+
useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
|
|
9
13
|
return (
|
|
10
14
|
<QueryClientProvider client={client}>
|
|
11
15
|
<RouterProvider router={router} />
|
|
@@ -6,58 +6,58 @@
|
|
|
6
6
|
Frontend.md §8.1 (4.5:1 text, 3:1 components). */
|
|
7
7
|
:root {
|
|
8
8
|
--radius: 0.5rem;
|
|
9
|
-
--background: oklch(0.
|
|
10
|
-
--foreground: oklch(0.
|
|
11
|
-
--card: oklch(
|
|
12
|
-
--card-foreground: oklch(0.
|
|
13
|
-
--popover: oklch(
|
|
14
|
-
--popover-foreground: oklch(0.
|
|
15
|
-
--primary: oklch(0.
|
|
16
|
-
--primary-foreground: oklch(
|
|
17
|
-
--secondary: oklch(0.
|
|
18
|
-
--secondary-foreground: oklch(0.
|
|
19
|
-
--muted: oklch(0.
|
|
20
|
-
--muted-foreground: oklch(0.
|
|
21
|
-
--accent: oklch(0.
|
|
22
|
-
--accent-foreground: oklch(0.
|
|
23
|
-
--destructive: oklch(0.
|
|
24
|
-
--border: oklch(0.
|
|
25
|
-
--input: oklch(0.
|
|
26
|
-
--ring: oklch(0.
|
|
27
|
-
--destructive-foreground: oklch(
|
|
28
|
-
--chart-1: oklch(0.
|
|
29
|
-
--chart-2: oklch(0.
|
|
30
|
-
--chart-3: oklch(0.
|
|
31
|
-
--chart-4: oklch(0.
|
|
32
|
-
--chart-5: oklch(0.
|
|
33
|
-
--sidebar: oklch(0.
|
|
34
|
-
--sidebar-foreground: oklch(0.
|
|
35
|
-
--sidebar-primary: oklch(0.
|
|
36
|
-
--sidebar-primary-foreground: oklch(
|
|
37
|
-
--sidebar-accent: oklch(0.
|
|
38
|
-
--sidebar-accent-foreground: oklch(0.
|
|
39
|
-
--sidebar-border: oklch(0.
|
|
40
|
-
--sidebar-ring: oklch(0.
|
|
41
|
-
--font-sans:
|
|
42
|
-
--font-serif:
|
|
43
|
-
--font-mono:
|
|
44
|
-
--shadow-color:
|
|
45
|
-
--shadow-opacity: 0.
|
|
46
|
-
--shadow-blur:
|
|
47
|
-
--shadow-spread:
|
|
9
|
+
--background: oklch(0.9911 0 0);
|
|
10
|
+
--foreground: oklch(0.2046 0 0);
|
|
11
|
+
--card: oklch(0.9911 0 0);
|
|
12
|
+
--card-foreground: oklch(0.2046 0 0);
|
|
13
|
+
--popover: oklch(0.9911 0 0);
|
|
14
|
+
--popover-foreground: oklch(0.4386 0 0);
|
|
15
|
+
--primary: oklch(0.8348 0.1302 160.9080);
|
|
16
|
+
--primary-foreground: oklch(0.2626 0.0147 166.4589);
|
|
17
|
+
--secondary: oklch(0.9940 0 0);
|
|
18
|
+
--secondary-foreground: oklch(0.2046 0 0);
|
|
19
|
+
--muted: oklch(0.9461 0 0);
|
|
20
|
+
--muted-foreground: oklch(0.2435 0 0);
|
|
21
|
+
--accent: oklch(0.9461 0 0);
|
|
22
|
+
--accent-foreground: oklch(0.2435 0 0);
|
|
23
|
+
--destructive: oklch(0.5523 0.1927 32.7272);
|
|
24
|
+
--border: oklch(0.9037 0 0);
|
|
25
|
+
--input: oklch(0.9731 0 0);
|
|
26
|
+
--ring: oklch(0.8348 0.1302 160.9080);
|
|
27
|
+
--destructive-foreground: oklch(0.9934 0.0032 17.2118);
|
|
28
|
+
--chart-1: oklch(0.8348 0.1302 160.9080);
|
|
29
|
+
--chart-2: oklch(0.6231 0.1880 259.8145);
|
|
30
|
+
--chart-3: oklch(0.6056 0.2189 292.7172);
|
|
31
|
+
--chart-4: oklch(0.7686 0.1647 70.0804);
|
|
32
|
+
--chart-5: oklch(0.6959 0.1491 162.4796);
|
|
33
|
+
--sidebar: oklch(0.9911 0 0);
|
|
34
|
+
--sidebar-foreground: oklch(0.5452 0 0);
|
|
35
|
+
--sidebar-primary: oklch(0.8348 0.1302 160.9080);
|
|
36
|
+
--sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589);
|
|
37
|
+
--sidebar-accent: oklch(0.9461 0 0);
|
|
38
|
+
--sidebar-accent-foreground: oklch(0.2435 0 0);
|
|
39
|
+
--sidebar-border: oklch(0.9037 0 0);
|
|
40
|
+
--sidebar-ring: oklch(0.8348 0.1302 160.9080);
|
|
41
|
+
--font-sans: Outfit, sans-serif;
|
|
42
|
+
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
43
|
+
--font-mono: monospace;
|
|
44
|
+
--shadow-color: #000000;
|
|
45
|
+
--shadow-opacity: 0.17;
|
|
46
|
+
--shadow-blur: 3px;
|
|
47
|
+
--shadow-spread: 0px;
|
|
48
48
|
--shadow-offset-x: 0px;
|
|
49
|
-
--shadow-offset-y:
|
|
50
|
-
--letter-spacing:
|
|
49
|
+
--shadow-offset-y: 1px;
|
|
50
|
+
--letter-spacing: 0.025em;
|
|
51
51
|
--spacing: 0.25rem;
|
|
52
|
-
--shadow-2xs: 0px
|
|
53
|
-
--shadow-xs: 0px
|
|
54
|
-
--shadow-sm: 0px
|
|
55
|
-
--shadow: 0px
|
|
56
|
-
--shadow-md: 0px
|
|
57
|
-
--shadow-lg: 0px
|
|
58
|
-
--shadow-xl: 0px
|
|
59
|
-
--shadow-2xl: 0px
|
|
60
|
-
--tracking-normal:
|
|
52
|
+
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
|
53
|
+
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
|
54
|
+
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
|
55
|
+
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
|
56
|
+
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
|
57
|
+
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
|
58
|
+
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
|
59
|
+
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
|
|
60
|
+
--tracking-normal: 0.025em;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
@theme inline {
|
|
@@ -83,9 +83,9 @@
|
|
|
83
83
|
--color-border: var(--border);
|
|
84
84
|
--color-input: var(--input);
|
|
85
85
|
--color-ring: var(--ring);
|
|
86
|
-
--font-sans:
|
|
87
|
-
--font-mono:
|
|
88
|
-
--font-serif:
|
|
86
|
+
--font-sans: Outfit, sans-serif;
|
|
87
|
+
--font-mono: monospace;
|
|
88
|
+
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
89
89
|
--radius: 0.5rem;
|
|
90
90
|
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
|
91
91
|
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
|
@@ -139,56 +139,56 @@
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
.dark {
|
|
142
|
-
--background: oklch(0.
|
|
143
|
-
--foreground: oklch(0.
|
|
144
|
-
--card: oklch(0.
|
|
145
|
-
--card-foreground: oklch(0.
|
|
146
|
-
--popover: oklch(0.
|
|
147
|
-
--popover-foreground: oklch(0.
|
|
148
|
-
--primary: oklch(0.
|
|
149
|
-
--primary-foreground: oklch(0.
|
|
150
|
-
--secondary: oklch(0.
|
|
151
|
-
--secondary-foreground: oklch(0.
|
|
152
|
-
--muted: oklch(0.
|
|
153
|
-
--muted-foreground: oklch(0.
|
|
154
|
-
--accent: oklch(0.
|
|
155
|
-
--accent-foreground: oklch(0.
|
|
156
|
-
--destructive: oklch(0.
|
|
157
|
-
--destructive-foreground: oklch(0.
|
|
158
|
-
--border: oklch(0.
|
|
159
|
-
--input: oklch(0.
|
|
160
|
-
--ring: oklch(0.
|
|
161
|
-
--chart-1: oklch(0.
|
|
162
|
-
--chart-2: oklch(0.
|
|
163
|
-
--chart-3: oklch(0.
|
|
164
|
-
--chart-4: oklch(0.
|
|
165
|
-
--chart-5: oklch(0.
|
|
142
|
+
--background: oklch(0.1822 0 0);
|
|
143
|
+
--foreground: oklch(0.9288 0.0126 255.5078);
|
|
144
|
+
--card: oklch(0.2046 0 0);
|
|
145
|
+
--card-foreground: oklch(0.9288 0.0126 255.5078);
|
|
146
|
+
--popover: oklch(0.2603 0 0);
|
|
147
|
+
--popover-foreground: oklch(0.7348 0 0);
|
|
148
|
+
--primary: oklch(0.4365 0.1044 156.7556);
|
|
149
|
+
--primary-foreground: oklch(0.9213 0.0135 167.1556);
|
|
150
|
+
--secondary: oklch(0.2603 0 0);
|
|
151
|
+
--secondary-foreground: oklch(0.9851 0 0);
|
|
152
|
+
--muted: oklch(0.2393 0 0);
|
|
153
|
+
--muted-foreground: oklch(0.7122 0 0);
|
|
154
|
+
--accent: oklch(0.3132 0 0);
|
|
155
|
+
--accent-foreground: oklch(0.9851 0 0);
|
|
156
|
+
--destructive: oklch(0.3123 0.0852 29.7877);
|
|
157
|
+
--destructive-foreground: oklch(0.9368 0.0045 34.3092);
|
|
158
|
+
--border: oklch(0.2809 0 0);
|
|
159
|
+
--input: oklch(0.2603 0 0);
|
|
160
|
+
--ring: oklch(0.8003 0.1821 151.7110);
|
|
161
|
+
--chart-1: oklch(0.8003 0.1821 151.7110);
|
|
162
|
+
--chart-2: oklch(0.7137 0.1434 254.6240);
|
|
163
|
+
--chart-3: oklch(0.7090 0.1592 293.5412);
|
|
164
|
+
--chart-4: oklch(0.8369 0.1644 84.4286);
|
|
165
|
+
--chart-5: oklch(0.7845 0.1325 181.9120);
|
|
166
166
|
--radius: 0.5rem;
|
|
167
|
-
--sidebar: oklch(0.
|
|
168
|
-
--sidebar-foreground: oklch(0.
|
|
169
|
-
--sidebar-primary: oklch(0.
|
|
170
|
-
--sidebar-primary-foreground: oklch(0.
|
|
171
|
-
--sidebar-accent: oklch(0.
|
|
172
|
-
--sidebar-accent-foreground: oklch(0.
|
|
173
|
-
--sidebar-border: oklch(0.
|
|
174
|
-
--sidebar-ring: oklch(0.
|
|
175
|
-
--font-sans:
|
|
176
|
-
--font-serif:
|
|
177
|
-
--font-mono:
|
|
178
|
-
--shadow-color:
|
|
179
|
-
--shadow-opacity: 0.
|
|
180
|
-
--shadow-blur:
|
|
181
|
-
--shadow-spread:
|
|
167
|
+
--sidebar: oklch(0.1822 0 0);
|
|
168
|
+
--sidebar-foreground: oklch(0.6301 0 0);
|
|
169
|
+
--sidebar-primary: oklch(0.4365 0.1044 156.7556);
|
|
170
|
+
--sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556);
|
|
171
|
+
--sidebar-accent: oklch(0.3132 0 0);
|
|
172
|
+
--sidebar-accent-foreground: oklch(0.9851 0 0);
|
|
173
|
+
--sidebar-border: oklch(0.2809 0 0);
|
|
174
|
+
--sidebar-ring: oklch(0.8003 0.1821 151.7110);
|
|
175
|
+
--font-sans: Outfit, sans-serif;
|
|
176
|
+
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
177
|
+
--font-mono: monospace;
|
|
178
|
+
--shadow-color: #000000;
|
|
179
|
+
--shadow-opacity: 0.17;
|
|
180
|
+
--shadow-blur: 3px;
|
|
181
|
+
--shadow-spread: 0px;
|
|
182
182
|
--shadow-offset-x: 0px;
|
|
183
|
-
--shadow-offset-y:
|
|
184
|
-
--letter-spacing:
|
|
183
|
+
--shadow-offset-y: 1px;
|
|
184
|
+
--letter-spacing: 0.025em;
|
|
185
185
|
--spacing: 0.25rem;
|
|
186
|
-
--shadow-2xs: 0px
|
|
187
|
-
--shadow-xs: 0px
|
|
188
|
-
--shadow-sm: 0px
|
|
189
|
-
--shadow: 0px
|
|
190
|
-
--shadow-md: 0px
|
|
191
|
-
--shadow-lg: 0px
|
|
192
|
-
--shadow-xl: 0px
|
|
193
|
-
--shadow-2xl: 0px
|
|
186
|
+
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
|
187
|
+
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
|
188
|
+
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
|
189
|
+
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
|
190
|
+
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
|
191
|
+
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
|
192
|
+
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
|
193
|
+
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
|
|
194
194
|
}
|