diff --git a/.gitignore b/.gitignore
index b786662..a464491 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-data/**
\ No newline at end of file
+data/**
+.env
\ No newline at end of file
diff --git a/Portifolio b/Portifolio
index cff6b19..4804185 100755
Binary files a/Portifolio and b/Portifolio differ
diff --git a/go.mod b/go.mod
index c570ca5..75a006e 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,11 @@ module Portifolio
go 1.25.7
-require github.com/mattn/go-sqlite3 v1.14.37 // indirect
+require github.com/mattn/go-sqlite3 v1.14.37
+
+require (
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
+)
+
+require github.com/FuLygon/go-totp/v2 v2.4.0
diff --git a/go.sum b/go.sum
index 9c79a75..afa922c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,8 @@
+github.com/FuLygon/go-totp/v2 v2.4.0 h1:NTgab5GCVHHGjIjjOzRKKKxa6PhMI9o/fs2fFrJqqhE=
+github.com/FuLygon/go-totp/v2 v2.4.0/go.mod h1:bEwZp8rat339MqPzXZ7FDl5eOJuXpQ35aYbk1qZbWLM=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
diff --git a/internal/middleware/session.go b/internal/middleware/session.go
new file mode 100644
index 0000000..cb3bc24
--- /dev/null
+++ b/internal/middleware/session.go
@@ -0,0 +1,58 @@
+package middleware
+
+import (
+ "net/http"
+ "sync"
+)
+
+// In-memory session store — swap for Redis/DB in production
+var (
+ sessionStore = make(map[string]bool)
+ mu sync.RWMutex
+)
+
+// RegisterSession adds a token to the store (call this after login)
+func RegisterSession(token string) {
+ mu.Lock()
+ defer mu.Unlock()
+ sessionStore[token] = true
+}
+
+// RevokeSession removes a token (call this on logout)
+func RevokeSession(token string) {
+ mu.Lock()
+ defer mu.Unlock()
+ delete(sessionStore, token)
+}
+
+// RequireSession is the middleware — wraps any handler that needs auth
+func RequireSession(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ // No cookie at all
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+
+ mu.RLock()
+ valid := sessionStore[cookie.Value]
+ mu.RUnlock()
+
+ if !valid {
+ // Cookie exists but token is unknown/expired
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ MaxAge: -1, // delete it
+ })
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/website/main.go b/internal/website/main.go
index a56ff4d..9fb66a0 100644
--- a/internal/website/main.go
+++ b/internal/website/main.go
@@ -1,13 +1,19 @@
package website
import (
+ "Portifolio/internal/middleware"
"Portifolio/internal/model"
"Portifolio/internal/service"
+ "crypto/rand"
_ "embed"
+ "encoding/base64"
"fmt"
"net/http"
+ "os"
"strconv"
+ "time"
+ "github.com/FuLygon/go-totp/v2"
_ "github.com/mattn/go-sqlite3"
)
@@ -21,13 +27,136 @@ func Getsite() http.HandlerFunc {
}
}
-//go:embed static/styles.css
-var styleCSS []byte
+//go:embed static/login.html
+var loginxHTML []byte
-func Getstylesheet() http.HandlerFunc {
+func GetAdminLogin() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(loginxHTML)
+ }
+}
+
+type contextKey string
+
+const ContextKeyRequestTime contextKey = "requestTime"
+
+func LoginHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // checked once when the handler is registered
+ envUsername := os.Getenv("username")
+ envPassword := os.Getenv("password")
+ envTOTPSecret := os.Getenv("AUTH_TOTP_SECRET")
+ if envUsername == "" || envPassword == "" || envTOTPSecret == "" {
+ http.Error(w, "env var missing", http.StatusInternalServerError)
+ }
+
+ ts, ok := r.Context().Value(ContextKeyRequestTime).(time.Time)
+ if !ok {
+ ts = time.Now()
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form data", http.StatusBadRequest)
+ return
+ }
+
+ password := r.FormValue("password")
+ username := r.FormValue("username")
+ code := r.FormValue("auth_code")
+ fmt.Println(username, password, code)
+
+ if password == "" || username == "" || code == "" {
+ http.Error(w, "form value is empty", http.StatusBadRequest)
+ return
+ }
+
+ if password != envPassword || username != envUsername {
+ http.Error(w, "username or password is not valid", http.StatusUnauthorized)
+ return
+ }
+
+ v := totp.Validator{
+ Algorithm: totp.AlgorithmSHA1,
+ Digits: 6,
+ Period: 30,
+ Secret: envTOTPSecret,
+ }
+
+ valid, err := v.ValidateWithTimestamp(code, ts.Unix())
+ if err != nil {
+ http.Error(w, fmt.Sprintf("error validating TOTP code: %s", err), http.StatusUnauthorized)
+ return
+ }
+ if !valid {
+ http.Error(w, "invalid auth code", http.StatusUnauthorized)
+ return
+ }
+
+ // In your login POST handler, after setting the cookie:
+ token := generateSessionToken()
+ middleware.RegisterSession(token) // <-- register before writing cookie
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: 86400,
+ })
+ w.Header().Set("HX-Redirect", "/admin")
+ w.WriteHeader(http.StatusOK)
+ }
+}
+
+// Simple session token generator
+func generateSessionToken() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return base64.URLEncoding.EncodeToString(b)
+}
+
+//go:embed static/admin.html
+var adminHTML []byte
+
+func GetAdmin() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(adminHTML)
+ }
+}
+
+//go:embed static/styles.css
+var styleCSSmain []byte
+
+func GetstylesheetMain() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
- w.Write(styleCSS)
+ w.Write(styleCSSmain)
+ }
+}
+
+//go:embed static/login.css
+var styleCSSlogin []byte
+
+func GetstylesheetLogin() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Write(styleCSSlogin)
+ }
+}
+
+//go:embed static/admin.css
+var styleCSSadmin []byte
+
+func GetstylesheetAdmin() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Write(styleCSSadmin)
}
}
diff --git a/internal/website/static/admin.css b/internal/website/static/admin.css
new file mode 100644
index 0000000..3341fa3
--- /dev/null
+++ b/internal/website/static/admin.css
@@ -0,0 +1,444 @@
+@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap');
+
+:root {
+ --bg: #f5efe7;;
+ --surface: #efefef;
+ --surface2:#e0e0e0;
+ --border: #8c8c8c;
+ --border2: #acacac;
+ --accent: #c8a96e;
+ --accent2: #f0d060;
+ --muted: #5a5a68;
+ --muted2: #3d3d4a;
+ --text: #000000;
+ --text-dim: #9e9a92;
+ --red: #f06060;
+ --blue: #60c0f0;
+ --radius: 2px;
+ --mono: 'Space Mono', monospace;
+ --serif: 'Fraunces', Georgia, serif;
+}
+
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html { font-size: 14px; }
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--mono);
+ min-height: 100vh;
+}
+
+/* GRID LAYOUT */
+.app {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ grid-template-rows: 56px 1fr;
+ min-height: 100vh;
+}
+
+/* TOP NAV */
+.topbar {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 0 24px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+.topbar-logo {
+ font-family: var(--serif);
+ font-size: 1rem;
+ font-style: italic;
+ font-weight: 300;
+ color: var(--text);
+ text-decoration: none;
+}
+.topbar-logo span { color: var(--accent); }
+.topbar-tag {
+ font-size: 0.71rem;
+ color: var(--muted2);
+ letter-spacing: 0.04em;
+}
+.topbar-spacer { flex: 1; }
+.status-pill {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 0.71rem;
+ color: var(--muted2);
+}
+.dot {
+ width: 7px; height: 7px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: pulse 2.5s ease-in-out infinite;
+}
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+.dot.err { background: var(--red); animation: none; }
+.dot.loading { background: var(--muted); animation: pulse 1s linear infinite; }
+
+/* SIDEBAR */
+.sidebar {
+ border-right: 1px solid var(--border);
+ background: var(--surface);
+ padding: 20px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.nav-section-label {
+ font-size: 0.62rem;
+ color: var(--muted);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ padding: 10px 18px 4px;
+}
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 18px;
+ font-size: 0.75rem;
+ color: var(--muted2);
+ text-decoration: none;
+ cursor: pointer;
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+ transition: color 0.15s, background 0.15s;
+ border-left: 2px solid transparent;
+}
+.nav-item:hover { color: var(--text); background: var(--surface2); }
+.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(200,240,96,0.05); }
+.nav-icon { width: 14px; opacity: 0.7; font-style: normal; }
+
+/* MAIN */
+.main {
+ padding: 28px 32px;
+ overflow-y: auto;
+ max-height: calc(100vh - 56px);
+}
+
+/* PAGE HEADER */
+.page-header {
+ margin-bottom: 28px;
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+}
+.page-title {
+ font-family: var(--serif);
+ font-size: 2rem;
+ font-weight: 300;
+ line-height: 1;
+}
+.page-title span { font-style: italic; color: var(--accent); }
+.page-subtitle {
+ font-size: 0.68rem;
+ color: var(--muted2);
+ padding-bottom: 4px;
+ letter-spacing: 0.04em;
+}
+
+/* SECTION */
+.section { margin-bottom: 32px; }
+.section-label {
+ font-size: 0.68rem;
+ color: var(--muted2);
+ letter-spacing: 0.08em;
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.section-label::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+.section-label span { color: var(--accent); }
+
+/* SUMMARY CARDS */
+.cards {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin-bottom: 32px;
+}
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ padding: 16px 18px;
+}
+.card-label {
+ font-size: 0.65rem;
+ color: var(--muted2);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.card-value {
+ font-family: var(--serif);
+ font-size: 1.6rem;
+ font-weight: 300;
+ color: var(--text);
+}
+.card-value.accent { color: var(--accent); }
+.card-value.dim { color: var(--muted); }
+
+/* PANEL / FORM */
+.panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+}
+.panel-header {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.panel-title {
+ font-size: 0.75rem;
+ letter-spacing: 0.04em;
+}
+.panel-title span { color: var(--accent); }
+.panel-body { padding: 20px; }
+
+/* FORM GRID */
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+.form-grid.two { grid-template-columns: repeat(2, 1fr); }
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.form-group.full { grid-column: 1 / -1; }
+.form-label {
+ font-size: 0.65rem;
+ color: var(--muted2);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+.form-label .required { color: var(--red); margin-left: 3px; }
+
+input, select, textarea {
+ background: var(--bg);
+ border: 1px solid var(--border2);
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ padding: 9px 12px;
+ border-radius: var(--radius);
+ outline: none;
+ width: 100%;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ -webkit-appearance: none;
+}
+input:focus, select:focus, textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(200,240,96,0.1);
+}
+input::placeholder { color: var(--muted); }
+select option { background: var(--surface2); }
+
+.type-toggle {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0;
+ border: 1px solid var(--border2);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.type-btn {
+ padding: 9px 8px;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ border: none;
+ cursor: pointer;
+ background: var(--bg);
+ color: var(--muted2);
+ transition: background 0.15s, color 0.15s;
+ letter-spacing: 0.03em;
+ border-right: 1px solid var(--border2);
+}
+.type-btn:last-child { border-right: none; }
+.type-btn.active-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
+.type-btn.active-sell { background: rgba(240,96,96,0.12); color: var(--red); }
+.type-btn.active-div { background: rgba(200,240,96,0.12); color: var(--accent); }
+
+/* DIVIDEND FIELDS */
+.dividend-fields {
+ grid-column: 1 / -1;
+ display: none;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+}
+.dividend-fields.visible { display: grid; }
+.div-label {
+ grid-column: 1 / -1;
+ font-size: 0.63rem;
+ color: var(--accent);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ margin-bottom: -8px;
+}
+
+/* FORM ACTIONS */
+.form-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+ grid-column: 1 / -1;
+}
+.btn {
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ padding: 9px 20px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ letter-spacing: 0.04em;
+ transition: background 0.15s, color 0.15s, transform 0.1s;
+ border-radius: var(--radius);
+}
+.btn:active { transform: scale(0.98); }
+.btn-primary {
+ background: var(--accent);
+ color: #0d0d0d;
+ font-weight: 700;
+}
+.btn-primary:hover { background: #d8ff70; }
+.btn-ghost {
+ background: transparent;
+ color: var(--muted2);
+ border-color: var(--border2);
+}
+.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
+
+/* FEEDBACK */
+.feedback {
+ font-size: 0.72rem;
+ padding: 4px 12px;
+ border-radius: var(--radius);
+ display: none;
+}
+.feedback.success { display: inline; color: var(--accent); }
+.feedback.error { display: inline; color: var(--red); }
+
+/* TABLE */
+.table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.75rem;
+}
+thead {
+ background: var(--surface2);
+}
+th {
+ padding: 10px 14px;
+ text-align: left;
+ font-size: 0.62rem;
+ font-weight: 400;
+ color: var(--muted2);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--border);
+}
+td {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text);
+ white-space: nowrap;
+}
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: rgba(255,255,255,0.025); }
+.td-sym {
+ font-weight: 700;
+ color: var(--accent);
+ font-size: 0.78rem;
+}
+.td-muted { color: var(--muted2); }
+.td-buy { color: var(--blue); }
+.td-sell { color: var(--red); }
+.td-div { color: var(--accent); }
+.td-right { text-align: right; }
+
+.badge {
+ font-size: 0.62rem;
+ padding: 2px 8px;
+ border-radius: 20px;
+ letter-spacing: 0.04em;
+}
+.badge-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
+.badge-sell { background: rgba(240,96,96,0.12); color: var(--red); }
+.badge-div { background: rgba(200,240,96,0.12); color: var(--accent); }
+
+/* TABS */
+.tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: -1px;
+}
+.tab {
+ padding: 10px 20px;
+ font-size: 0.72rem;
+ cursor: pointer;
+ color: var(--muted2);
+ border: none;
+ background: none;
+ font-family: var(--mono);
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s;
+ letter-spacing: 0.03em;
+}
+.tab:hover { color: var(--text); }
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+/* PILL TAG */
+.tag-pill {
+ font-size: 0.60rem;
+ padding: 2px 7px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--muted2);
+ border-radius: 20px;
+ letter-spacing: 0.04em;
+}
+
+/* PAGES */
+.page { display: none; }
+.page.active { display: block; }
+
+/* RESPONSIVE NOTE */
+@media(max-width:900px) {
+ .app { grid-template-columns: 1fr; }
+ .sidebar { display: none; }
+ .cards { grid-template-columns: repeat(2, 1fr); }
+ .form-grid { grid-template-columns: 1fr; }
+ .dividend-fields { grid-template-columns: 1fr; }
+}
\ No newline at end of file
diff --git a/internal/website/static/admin.html b/internal/website/static/admin.html
new file mode 100644
index 0000000..d90bfe8
--- /dev/null
+++ b/internal/website/static/admin.html
@@ -0,0 +1,582 @@
+
+
+
+
+
+Portfolio Admin — Samantha Friis
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// positions
+
+
+
+
+ | Symbol | Currency | Shares |
+ Cost Basis | Weight |
+
+
+
+ | loading… |
+
+
+
+
+
// companies — tracked universe
+
+
+
+
+ | Symbol | Currency | Price |
+ Shares Outstanding | Market Cap |
+
+
+
+ | loading… |
+
+
+
+
+
+
+
+
+
+
+
+
+ | Symbol | Currency | Shares |
+ Cost Basis | Weight |
+
+
+
+ | loading… |
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date | Symbol | Type |
+ Shares | Price | Currency | Total |
+
+
+
+ | loading… |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/website/static/index.html b/internal/website/static/index.html
index e591dc9..399c538 100644
--- a/internal/website/static/index.html
+++ b/internal/website/static/index.html
@@ -5,14 +5,15 @@
Portfolio — Samantha Friis
-
+
- ← back
+ ← back,
+ login
// portfolio
Investment
Portfolio
diff --git a/internal/website/static/login.css b/internal/website/static/login.css
new file mode 100644
index 0000000..281dd3f
--- /dev/null
+++ b/internal/website/static/login.css
@@ -0,0 +1,224 @@
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+ :root {
+ --ink: #1a1714;
+ --paper: #f5f0e8;
+ --accent: #c4622d;
+ --muted: #8c8070;
+ --border: #d6cfc3;
+ --field-bg: #ece7dd;
+ }
+
+ html, body {
+ height: 100%;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: 'Space Mono', monospace;
+ font-size: 13px;
+ }
+
+ body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 24px;
+ }
+
+ /* Grain overlay */
+ body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
+ pointer-events: none;
+ z-index: 0;
+ opacity: 0.5;
+ }
+
+ .container {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 420px;
+ animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ @keyframes rise {
+ from { opacity: 0; transform: translateY(18px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ header {
+ margin-bottom: 40px;
+ }
+
+ .back {
+ display: inline-block;
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ color: var(--muted);
+ text-decoration: none;
+ letter-spacing: 0.04em;
+ margin-bottom: 28px;
+ transition: color 0.2s;
+ }
+ .back:hover { color: var(--ink); }
+
+ .tag {
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ color: var(--accent);
+ letter-spacing: 0.08em;
+ margin-bottom: 10px;
+ }
+
+ h1 {
+ font-family: 'Fraunces', serif;
+ font-weight: 300;
+ font-size: clamp(36px, 8vw, 52px);
+ line-height: 1.05;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+ }
+
+ h1 span {
+ font-style: italic;
+ color: var(--accent);
+ }
+
+ .header-sub {
+ margin-top: 10px;
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.06em;
+ }
+
+ /* Rule */
+ .rule {
+ width: 100%;
+ height: 1px;
+ background: var(--border);
+ margin: 32px 0;
+ }
+
+ /* Form */
+ .form-group {
+ margin-bottom: 20px;
+ }
+
+ label {
+ display: block;
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 7px;
+ }
+
+ input[type="text"],
+ input[type="password"] {
+ width: 100%;
+ padding: 11px 14px;
+ background: var(--field-bg);
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ font-family: 'Space Mono', monospace;
+ font-size: 13px;
+ color: var(--ink);
+ outline: none;
+ transition: border-color 0.2s, background 0.2s;
+ -webkit-appearance: none;
+ }
+
+ input[type="text"]:focus,
+ input[type="password"]:focus {
+ border-color: var(--accent);
+ background: var(--paper);
+ }
+
+ input::placeholder { color: var(--muted); opacity: 0.7; }
+
+ .form-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 28px;
+ gap: 16px;
+ }
+
+ .btn-login {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--ink);
+ color: var(--paper);
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ border: none;
+ padding: 12px 24px;
+ cursor: pointer;
+ border-radius: 2px;
+ transition: background 0.2s, transform 0.15s;
+ }
+
+ .btn-login:hover {
+ background: var(--accent);
+ transform: translateY(-1px);
+ }
+ .btn-login:active { transform: translateY(0); }
+
+ .btn-login svg {
+ transition: transform 0.2s;
+ }
+ .btn-login:hover svg { transform: translateX(3px); }
+
+ .forgot {
+ font-size: 11px;
+ color: var(--muted);
+ text-decoration: none;
+ letter-spacing: 0.03em;
+ border-bottom: 1px solid transparent;
+ transition: color 0.2s, border-color 0.2s;
+ }
+ .forgot:hover { color: var(--ink); border-color: var(--ink); }
+
+ footer {
+ margin-top: 48px;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ border-top: 1px solid var(--border);
+ padding-top: 16px;
+ animation: rise 0.6s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ footer a {
+ font-size: 10px;
+ color: var(--muted);
+ text-decoration: none;
+ letter-spacing: 0.04em;
+ transition: color 0.2s;
+ }
+ footer a:hover { color: var(--accent); }
+
+ .footer-copy {
+ font-size: 10px;
+ color: var(--border);
+ letter-spacing: 0.04em;
+ }
+
+ /* Decorative corner mark */
+ .corner-mark {
+ position: fixed;
+ bottom: 24px;
+ right: 28px;
+ font-family: 'Fraunces', serif;
+ font-style: italic;
+ font-size: 11px;
+ color: var(--border);
+ letter-spacing: 0.06em;
+ z-index: 1;
+ }
\ No newline at end of file
diff --git a/internal/website/static/login.html b/internal/website/static/login.html
new file mode 100644
index 0000000..4e73d5f
--- /dev/null
+++ b/internal/website/static/login.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ Portfolio — Samantha Friis
+
+
+
+
+
+
+
+ ← back
+ // portfolio
+ Investment
Portfolio
+
+
+
+
+
+
+
+
+
+
+SF — 2026
+
+
+
\ No newline at end of file
diff --git a/internal/website/static/styles.css b/internal/website/static/styles.css
index 7e38c14..43323ae 100644
--- a/internal/website/static/styles.css
+++ b/internal/website/static/styles.css
@@ -1,5 +1,5 @@
:root {
- --bg: #f4f4f4;
+ --bg: #f5efe7;;
--surface: #efefef;
--surface2:#e0e0e0;
--border: #8c8c8c;
diff --git a/main.go b/main.go
index 211bb1a..443bc27 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@ package main
import (
"Portifolio/internal/database"
+ "Portifolio/internal/middleware"
"Portifolio/internal/service"
"Portifolio/internal/website"
"database/sql"
@@ -9,6 +10,9 @@ import (
"fmt"
"log"
"net/http"
+ "os"
+
+ "github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
)
@@ -32,12 +36,32 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}
+func EnvCheck() error {
+
+ if os.Getenv("username") == "" {
+ return fmt.Errorf("Missing: username")
+ }
+ if os.Getenv("password") == "" {
+ return fmt.Errorf("Missing: password")
+ }
+ if os.Getenv("AUTH_TOTP_SECRET") == "" {
+ return fmt.Errorf("Missing: AUTH_TOTP_SECRET")
+ }
+
+ return nil
+}
+
func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
svc := service.New(db) // ← create it here, once
// ── Website (HTMX fragments) ──────────────────────────────
mux.HandleFunc("/", website.Getsite())
- mux.HandleFunc("/style", website.Getstylesheet())
+ mux.HandleFunc("/styles/main", website.GetstylesheetMain())
+ mux.HandleFunc("/styles/login", website.GetstylesheetLogin())
+ mux.HandleFunc("/styles/admin", website.GetstylesheetAdmin())
+ mux.Handle("/admin", middleware.RequireSession(website.GetAdmin()))
+ mux.HandleFunc("/login", website.GetAdminLogin())
+ mux.HandleFunc("POST /auth/login", website.LoginHandler())
mux.HandleFunc("/health/fragment", website.HealthFragment(svc))
mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc))
mux.HandleFunc("/company/fragment", website.CompanyFragment(svc))
@@ -47,24 +71,19 @@ func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
// ── API (JSON) ────────────────────────────────────────────
mux.HandleFunc("/health", svc.HealthHandler())
- // Trades
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
- // Positions
mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler())
//mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc))
//mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc))
- // Company
mux.HandleFunc("POST /company/add", svc.AddCompanyHandler())
mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler())
mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories())
//mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc))
//mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc))
- // Currency
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
- // Revenue
mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler())
mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler())
}
@@ -88,6 +107,15 @@ func main() {
log.Fatal("Failed to connect to database:", err)
}
+ err = godotenv.Load(".env")
+ if err != nil {
+ log.Fatal("Error loading .env file")
+ }
+ err = EnvCheck()
+ if err != nil {
+ log.Fatal("Env vars not found:", err)
+ }
+
database.InitDB(db)
fmt.Println("Connected to SQLite database")