yep
This commit is contained in:
parent
4f6d7cfe60
commit
8fa8d04427
10 changed files with 231 additions and 71 deletions
14
.env.example
14
.env.example
|
|
@ -7,10 +7,12 @@ OIDC_ISSUER=https://your-oidc-provider.example.com/
|
|||
OIDC_AUDIENCE=your-api-audience
|
||||
OIDC_JWKS_URL=https://your-oidc-provider.example.com/.well-known/jwks.json
|
||||
|
||||
# Optional metadata for frontend OIDC integrations later:
|
||||
OIDC_AUTH_URL=
|
||||
OIDC_TOKEN_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_REDIRECT_URL=
|
||||
OIDC_LOGOUT_URL=
|
||||
# Required when DEV_MODE=false:
|
||||
OIDC_AUTH_URL=https://your-oidc-provider.example.com/authorize
|
||||
OIDC_TOKEN_URL=https://your-oidc-provider.example.com/oauth/token
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
|
||||
|
||||
# Optional:
|
||||
OIDC_LOGOUT_URL=
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -32,17 +32,20 @@ In this mode, protected routes always authenticate as:
|
|||
OIDC variables are not required when `DEV_MODE=true`.
|
||||
|
||||
## Auth flow
|
||||
API and protected pages require `Authorization: Bearer <access_token>` when `DEV_MODE=false`.
|
||||
|
||||
For quick local testing, set in browser console:
|
||||
```js
|
||||
localStorage.setItem("access_token", "<your_oidc_access_token>");
|
||||
```
|
||||
When `DEV_MODE=false`, login is handled directly by the app:
|
||||
- `GET /auth/login` redirects to your OIDC provider
|
||||
- `GET /auth/callback` exchanges code for access token using `OIDC_CLIENT_ID` + `OIDC_CLIENT_SECRET`
|
||||
- Access token is stored in secure HttpOnly session cookie
|
||||
- Protected routes validate that token via JWKS
|
||||
- `GET /auth/logout` clears the local session (and optionally redirects to provider logout URL)
|
||||
|
||||
## Key routes
|
||||
- `GET /dashboard`: upload form
|
||||
- `GET /skills`: browse listings and buy
|
||||
- `GET /my-skills`: purchased skills and copy/download access
|
||||
- `GET /auth/login`: start OIDC login
|
||||
- `GET /auth/callback`: OIDC callback
|
||||
- `GET /auth/logout`: logout
|
||||
- `POST /api/upload`: upload and list a skill
|
||||
- `POST /api/purchase`: buy a skill
|
||||
- `GET /api/my-skills`: owned purchased list
|
||||
|
|
|
|||
217
auth.go
217
auth.go
|
|
@ -2,14 +2,17 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -21,6 +24,11 @@ type contextKey string
|
|||
|
||||
const userContextKey contextKey = "auth_user"
|
||||
|
||||
const (
|
||||
sessionCookieName = "session_token"
|
||||
stateCookieName = "oidc_state"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Sub string
|
||||
Email string
|
||||
|
|
@ -28,11 +36,17 @@ type User struct {
|
|||
}
|
||||
|
||||
type Auth struct {
|
||||
issuer string
|
||||
audience string
|
||||
jwksURL string
|
||||
devMode bool
|
||||
db *sql.DB
|
||||
issuer string
|
||||
audience string
|
||||
jwksURL string
|
||||
authURL string
|
||||
tokenURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURL string
|
||||
logoutURL string
|
||||
devMode bool
|
||||
db *sql.DB
|
||||
|
||||
mu sync.RWMutex
|
||||
keyByKid map[string]*rsa.PublicKey
|
||||
|
|
@ -41,12 +55,18 @@ type Auth struct {
|
|||
|
||||
func NewAuth(cfg Config) (*Auth, error) {
|
||||
a := &Auth{
|
||||
issuer: cfg.OIDCIssuer,
|
||||
audience: cfg.OIDCAudience,
|
||||
jwksURL: cfg.OIDCJWKSURL,
|
||||
devMode: cfg.DevMode,
|
||||
keyByKid: map[string]*rsa.PublicKey{},
|
||||
lastSyncAt: time.Time{},
|
||||
issuer: cfg.OIDCIssuer,
|
||||
audience: cfg.OIDCAudience,
|
||||
jwksURL: cfg.OIDCJWKSURL,
|
||||
authURL: cfg.OIDCAuthURL,
|
||||
tokenURL: cfg.OIDCTokenURL,
|
||||
clientID: cfg.OIDCClientID,
|
||||
clientSecret: cfg.OIDCClientSecret,
|
||||
redirectURL: cfg.OIDCRedirect,
|
||||
logoutURL: cfg.OIDCLogoutURL,
|
||||
devMode: cfg.DevMode,
|
||||
keyByKid: map[string]*rsa.PublicKey{},
|
||||
lastSyncAt: time.Time{},
|
||||
}
|
||||
if a.devMode {
|
||||
return a, nil
|
||||
|
|
@ -61,12 +81,114 @@ func (a *Auth) WithDB(db *sql.DB) {
|
|||
a.db = db
|
||||
}
|
||||
|
||||
func (a *Auth) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if a.devMode {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
state, err := randomToken(24)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to initialize login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 600,
|
||||
})
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("client_id", a.clientID)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid profile email")
|
||||
q.Set("redirect_uri", a.redirectURL)
|
||||
q.Set("state", state)
|
||||
http.Redirect(w, r, a.authURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *Auth) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if a.devMode {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
stateCookie, err := r.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "missing state cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stateParam := r.URL.Query().Get("state")
|
||||
if stateParam == "" || stateParam != stateCookie.Value {
|
||||
http.Error(w, "invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := a.exchangeCode(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, "token exchange failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.userFromToken(token); err != nil {
|
||||
http.Error(w, "invalid access token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 3600,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *Auth) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
if !a.devMode && a.logoutURL != "" {
|
||||
http.Redirect(w, r, a.logoutURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *Auth) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
user User
|
||||
err error
|
||||
)
|
||||
|
||||
if a.devMode {
|
||||
user = User{
|
||||
Sub: "demo-user-001",
|
||||
|
|
@ -74,9 +196,14 @@ func (a *Auth) RequireAuth(next http.Handler) http.Handler {
|
|||
Name: "Demo User",
|
||||
}
|
||||
} else {
|
||||
user, err = a.authenticateRequest(r)
|
||||
cookie, cErr := r.Cookie(sessionCookieName)
|
||||
if cErr != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, err = a.userFromToken(cookie.Value)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -106,19 +233,50 @@ func MustUserFromContext(ctx context.Context) User {
|
|||
return User{}
|
||||
}
|
||||
|
||||
func (a *Auth) authenticateRequest(r *http.Request) (User, error) {
|
||||
token := extractBearer(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
return User{}, errors.New("missing token")
|
||||
func (a *Auth) exchangeCode(ctx context.Context, code string) (string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", a.clientID)
|
||||
form.Set("client_secret", a.clientSecret)
|
||||
form.Set("redirect_uri", a.redirectURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", fmt.Errorf("token endpoint failed: %s %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return "", errors.New("missing access_token")
|
||||
}
|
||||
return tr.AccessToken, nil
|
||||
}
|
||||
|
||||
func (a *Auth) userFromToken(token string) (User, error) {
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"}))
|
||||
parsed, err := parser.Parse(token, func(t *jwt.Token) (any, error) {
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return nil, errors.New("missing kid")
|
||||
}
|
||||
key, err := a.lookupKey(r.Context(), kid)
|
||||
key, err := a.lookupKey(context.Background(), kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -132,7 +290,6 @@ func (a *Auth) authenticateRequest(r *http.Request) (User, error) {
|
|||
if !ok {
|
||||
return User{}, errors.New("invalid claims")
|
||||
}
|
||||
|
||||
if iss, _ := claims["iss"].(string); iss != a.issuer {
|
||||
return User{}, errors.New("invalid issuer")
|
||||
}
|
||||
|
|
@ -189,7 +346,6 @@ func (a *Auth) refreshKeys(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("jwks request failed: %s", resp.Status)
|
||||
}
|
||||
|
|
@ -212,7 +368,6 @@ func (a *Auth) refreshKeys(ctx context.Context) error {
|
|||
if len(newKeys) == 0 {
|
||||
return errors.New("no usable jwks keys")
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.keyByKid = newKeys
|
||||
a.lastSyncAt = time.Now()
|
||||
|
|
@ -240,17 +395,6 @@ func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) {
|
|||
return &rsa.PublicKey{N: n, E: e}, nil
|
||||
}
|
||||
|
||||
func extractBearer(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(value, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func audienceMatches(audValue any, expected string) bool {
|
||||
switch v := audValue.(type) {
|
||||
case string:
|
||||
|
|
@ -265,3 +409,12 @@ func audienceMatches(audValue any, expected string) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type Config struct {
|
|||
OIDCAuthURL string
|
||||
OIDCTokenURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirect string
|
||||
OIDCLogoutURL string
|
||||
}
|
||||
|
|
@ -37,6 +38,7 @@ func LoadConfig() (Config, error) {
|
|||
OIDCAuthURL: os.Getenv("OIDC_AUTH_URL"),
|
||||
OIDCTokenURL: os.Getenv("OIDC_TOKEN_URL"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
OIDCRedirect: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
OIDCLogoutURL: os.Getenv("OIDC_LOGOUT_URL"),
|
||||
}
|
||||
|
|
@ -47,6 +49,9 @@ func LoadConfig() (Config, error) {
|
|||
if !cfg.DevMode && (cfg.OIDCIssuer == "" || cfg.OIDCAudience == "" || cfg.OIDCJWKSURL == "") {
|
||||
return Config{}, fmt.Errorf("OIDC_ISSUER, OIDC_AUDIENCE, and OIDC_JWKS_URL are required")
|
||||
}
|
||||
if !cfg.DevMode && (cfg.OIDCAuthURL == "" || cfg.OIDCTokenURL == "" || cfg.OIDCClientID == "" || cfg.OIDCClientSecret == "" || cfg.OIDCRedirect == "") {
|
||||
return Config{}, fmt.Errorf("OIDC_AUTH_URL, OIDC_TOKEN_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL are required when DEV_MODE=false")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -23,6 +23,7 @@ type App struct {
|
|||
auth *Auth
|
||||
templates *template.Template
|
||||
storageDir string
|
||||
devMode bool
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
|
|
@ -83,11 +84,15 @@ func main() {
|
|||
auth: auth,
|
||||
templates: tmpl,
|
||||
storageDir: cfg.StorageDir,
|
||||
devMode: cfg.DevMode,
|
||||
}
|
||||
app.auth.WithDB(db)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
mux.HandleFunc("/auth/login", app.auth.HandleLogin)
|
||||
mux.HandleFunc("/auth/callback", app.auth.HandleCallback)
|
||||
mux.HandleFunc("/auth/logout", app.auth.HandleLogout)
|
||||
mux.Handle("/uploads/", app.auth.RequireAuth(http.HandlerFunc(app.handleDownload)))
|
||||
mux.HandleFunc("/", app.handleHome)
|
||||
mux.Handle("/dashboard", app.auth.RequireAuth(http.HandlerFunc(app.handleDashboard)))
|
||||
|
|
@ -133,7 +138,8 @@ func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
a.render(w, "home.html", map[string]any{
|
||||
"Title": "LLM Skill Marketplace",
|
||||
"Title": "LLM Skill Marketplace",
|
||||
"DevMode": a.devMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
(function () {
|
||||
function getToken() {
|
||||
return localStorage.getItem("access_token") || "";
|
||||
}
|
||||
|
||||
function authHeaders(extra) {
|
||||
var headers = extra || {};
|
||||
var token = getToken();
|
||||
if (token) {
|
||||
headers.Authorization = "Bearer " + token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function loadSkills() {
|
||||
var grid = document.getElementById("skill-grid");
|
||||
if (!grid) return;
|
||||
|
||||
var res = await fetch("/api/skills", { headers: authHeaders() });
|
||||
var res = await fetch("/api/skills", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
grid.innerHTML = '<p class="hint">Set `access_token` in localStorage with a valid OIDC JWT.</p>';
|
||||
grid.innerHTML = '<p class="hint">Sign in first to browse skills.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +36,8 @@
|
|||
|
||||
var res = await fetch("/api/purchase", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/x-www-form-urlencoded" }),
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
|
|
@ -64,9 +52,9 @@
|
|||
var grid = document.getElementById("my-skill-grid");
|
||||
if (!grid) return;
|
||||
|
||||
var res = await fetch("/api/my-skills", { headers: authHeaders() });
|
||||
var res = await fetch("/api/my-skills", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
grid.innerHTML = '<p class="hint">Set `access_token` in localStorage with a valid OIDC JWT.</p>';
|
||||
grid.innerHTML = '<p class="hint">Sign in first to view your purchased skills.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +95,7 @@
|
|||
var data = new FormData(form);
|
||||
var res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: data
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
|
@ -132,4 +120,3 @@
|
|||
loadSkills();
|
||||
loadMySkills();
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
<div class="actions">
|
||||
<a class="btn primary" href="/skills">Browse Skills</a>
|
||||
<a class="btn" href="/my-skills">My Purchases</a>
|
||||
<a class="btn" href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
|
|
@ -32,4 +33,3 @@
|
|||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@
|
|||
<h1>Sell, buy, and reuse high quality LLM skills</h1>
|
||||
<p class="lead">Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.</p>
|
||||
<div class="actions">
|
||||
<a class="btn primary" href="/dashboard">Open Dashboard</a>
|
||||
<a class="btn primary" href="/auth/login">Sign In</a>
|
||||
<a class="btn" href="/dashboard">Open Dashboard</a>
|
||||
</div>
|
||||
<p class="hint">Authentication uses your external OIDC provider token via Authorization header.</p>
|
||||
{{if .DevMode}}
|
||||
<p class="hint">Dev mode is active. Authentication uses the demo user automatically.</p>
|
||||
{{else}}
|
||||
<p class="hint">Authentication is handled by your OIDC provider using secure session cookies.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<div class="actions">
|
||||
<a class="btn" href="/dashboard">Back to Dashboard</a>
|
||||
<a class="btn" href="/skills">Browse More Skills</a>
|
||||
<a class="btn" href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
|
|
@ -23,4 +24,3 @@
|
|||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<div class="actions">
|
||||
<a class="btn" href="/dashboard">Back to Dashboard</a>
|
||||
<a class="btn" href="/my-skills">My Purchases</a>
|
||||
<a class="btn" href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
|
|
@ -23,4 +24,3 @@
|
|||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue