Add multi-user auth with sessions and login UI
- auth: POST /auth validates PAM, returns session token - auth: GET /me validates Bearer token, returns username - auth: POST /logout destroys session - auth: POST /users creates Linux user via useradd+chpasswd - session.c: in-memory token store (1024 slots, pthread mutex, /dev/urandom tokens) - user.c: fork/exec useradd + chpasswd with username validation - ui: login overlay shown until authenticated (x-cloak) - ui: token persisted in sessionStorage, validated on page load - ui: username displayed in dock after login - ui: apps only load after successful auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ac3215abf5
commit
44f3192b78
10 changed files with 470 additions and 26 deletions
|
|
@ -1,7 +1,7 @@
|
|||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2
|
||||
LIBS = -lpam -lmicrohttpd
|
||||
SRC = src/main.c src/pam_auth.c
|
||||
LIBS = -lpam -lmicrohttpd -lpthread
|
||||
SRC = src/main.c src/pam_auth.c src/session.c src/user.c
|
||||
OUT = crimata-auth
|
||||
|
||||
.PHONY: all clean
|
||||
|
|
|
|||
105
auth/src/main.c
105
auth/src/main.c
|
|
@ -3,6 +3,8 @@
|
|||
#include <string.h>
|
||||
#include <microhttpd.h>
|
||||
#include "pam_auth.h"
|
||||
#include "session.h"
|
||||
#include "user.h"
|
||||
|
||||
#define PORT 7700
|
||||
#define MAX_BODY 4096
|
||||
|
|
@ -17,7 +19,7 @@ static const char *json_get(const char *json, const char *key, char *out, size_t
|
|||
if (!pos) return NULL;
|
||||
|
||||
pos += strlen(search);
|
||||
while (*pos == ' ' || *pos == ':' || *pos == ' ') pos++;
|
||||
while (*pos == ' ' || *pos == ':') pos++;
|
||||
if (*pos != '"') return NULL;
|
||||
pos++;
|
||||
|
||||
|
|
@ -35,11 +37,21 @@ static enum MHD_Result send_json(struct MHD_Connection *conn,
|
|||
struct MHD_Response *resp = MHD_create_response_from_buffer(
|
||||
strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
|
||||
MHD_add_response_header(resp, "Content-Type", "application/json");
|
||||
MHD_add_response_header(resp, "Access-Control-Allow-Origin", "*");
|
||||
enum MHD_Result ret = MHD_queue_response(conn, status, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Extract Bearer token from Authorization header */
|
||||
static const char *bearer_token(struct MHD_Connection *conn)
|
||||
{
|
||||
const char *auth = MHD_lookup_connection_value(conn, MHD_HEADER_KIND, "Authorization");
|
||||
if (!auth) return NULL;
|
||||
if (strncmp(auth, "Bearer ", 7) != 0) return NULL;
|
||||
return auth + 7;
|
||||
}
|
||||
|
||||
/* ── Request context ──────────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
|
|
@ -54,6 +66,7 @@ static enum MHD_Result handle_health(struct MHD_Connection *conn)
|
|||
return send_json(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
/* POST /auth { username, password } → { token } */
|
||||
static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body)
|
||||
{
|
||||
char username[256] = {0};
|
||||
|
|
@ -65,14 +78,80 @@ static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body
|
|||
"{\"success\":false,\"error\":\"username and password required\"}");
|
||||
}
|
||||
|
||||
int result = authenticate(username, password);
|
||||
if (result == 0) {
|
||||
return send_json(conn, MHD_HTTP_OK, "{\"success\":true}");
|
||||
}
|
||||
if (authenticate(username, password) != 0) {
|
||||
return send_json(conn, MHD_HTTP_UNAUTHORIZED,
|
||||
"{\"success\":false,\"error\":\"invalid credentials\"}");
|
||||
}
|
||||
|
||||
const char *token = session_create(username);
|
||||
if (!token) {
|
||||
return send_json(conn, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"success\":false,\"error\":\"no session slots\"}");
|
||||
}
|
||||
|
||||
char resp[320];
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"success\":true,\"token\":\"%s\",\"username\":\"%s\"}", token, username);
|
||||
return send_json(conn, MHD_HTTP_OK, resp);
|
||||
}
|
||||
|
||||
/* GET /me Authorization: Bearer <token> → { username } */
|
||||
static enum MHD_Result handle_me(struct MHD_Connection *conn)
|
||||
{
|
||||
const char *token = bearer_token(conn);
|
||||
if (!token) {
|
||||
return send_json(conn, MHD_HTTP_UNAUTHORIZED,
|
||||
"{\"error\":\"missing token\"}");
|
||||
}
|
||||
|
||||
const char *username = session_lookup(token);
|
||||
if (!username) {
|
||||
return send_json(conn, MHD_HTTP_UNAUTHORIZED,
|
||||
"{\"error\":\"invalid token\"}");
|
||||
}
|
||||
|
||||
char resp[320];
|
||||
snprintf(resp, sizeof(resp), "{\"username\":\"%s\"}", username);
|
||||
return send_json(conn, MHD_HTTP_OK, resp);
|
||||
}
|
||||
|
||||
/* POST /logout Authorization: Bearer <token> */
|
||||
static enum MHD_Result handle_logout(struct MHD_Connection *conn)
|
||||
{
|
||||
const char *token = bearer_token(conn);
|
||||
if (token) session_destroy(token);
|
||||
return send_json(conn, MHD_HTTP_OK, "{\"success\":true}");
|
||||
}
|
||||
|
||||
/* POST /users { username, password } → create Linux user */
|
||||
static enum MHD_Result handle_create_user(struct MHD_Connection *conn, const char *body)
|
||||
{
|
||||
/* Caller must be authenticated */
|
||||
const char *token = bearer_token(conn);
|
||||
if (!token || !session_lookup(token)) {
|
||||
return send_json(conn, MHD_HTTP_UNAUTHORIZED,
|
||||
"{\"success\":false,\"error\":\"authentication required\"}");
|
||||
}
|
||||
|
||||
char username[256] = {0};
|
||||
char password[256] = {0};
|
||||
|
||||
if (!json_get(body, "username", username, sizeof(username)) ||
|
||||
!json_get(body, "password", password, sizeof(password))) {
|
||||
return send_json(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"success\":false,\"error\":\"username and password required\"}");
|
||||
}
|
||||
|
||||
if (user_create(username, password) != 0) {
|
||||
return send_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"success\":false,\"error\":\"could not create user\"}");
|
||||
}
|
||||
|
||||
char resp[320];
|
||||
snprintf(resp, sizeof(resp), "{\"success\":true,\"username\":\"%s\"}", username);
|
||||
return send_json(conn, MHD_HTTP_CREATED, resp);
|
||||
}
|
||||
|
||||
/* ── Main handler ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static enum MHD_Result handler(void *cls,
|
||||
|
|
@ -90,8 +169,16 @@ static enum MHD_Result handler(void *cls,
|
|||
if (strcmp(url, "/health") == 0 && strcmp(method, "GET") == 0)
|
||||
return handle_health(conn);
|
||||
|
||||
/* Auth — collect body first */
|
||||
if (strcmp(url, "/auth") == 0 && strcmp(method, "POST") == 0) {
|
||||
/* GET /me */
|
||||
if (strcmp(url, "/me") == 0 && strcmp(method, "GET") == 0)
|
||||
return handle_me(conn);
|
||||
|
||||
/* Routes that need a request body */
|
||||
if ((strcmp(url, "/auth") == 0 ||
|
||||
strcmp(url, "/logout") == 0 ||
|
||||
strcmp(url, "/users") == 0) &&
|
||||
(strcmp(method, "POST") == 0))
|
||||
{
|
||||
if (!*con_cls) {
|
||||
request_ctx_t *ctx = calloc(1, sizeof(request_ctx_t));
|
||||
if (!ctx) return MHD_NO;
|
||||
|
|
@ -110,7 +197,9 @@ static enum MHD_Result handler(void *cls,
|
|||
return MHD_YES;
|
||||
}
|
||||
|
||||
return handle_auth(conn, ctx->body);
|
||||
if (strcmp(url, "/auth") == 0) return handle_auth(conn, ctx->body);
|
||||
if (strcmp(url, "/logout") == 0) return handle_logout(conn);
|
||||
if (strcmp(url, "/users") == 0) return handle_create_user(conn, ctx->body);
|
||||
}
|
||||
|
||||
return send_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}");
|
||||
|
|
|
|||
93
auth/src/session.c
Normal file
93
auth/src/session.c
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#include "session.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#define MAX_SESSIONS 1024
|
||||
#define TOKEN_LEN 32
|
||||
|
||||
typedef struct {
|
||||
char token[TOKEN_LEN + 1];
|
||||
char username[256];
|
||||
int active;
|
||||
} session_t;
|
||||
|
||||
static session_t sessions[MAX_SESSIONS];
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/* Generate a random hex token */
|
||||
static void gen_token(char *out)
|
||||
{
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
unsigned char buf[TOKEN_LEN / 2];
|
||||
FILE *f = fopen("/dev/urandom", "rb");
|
||||
if (f) {
|
||||
fread(buf, 1, sizeof(buf), f);
|
||||
fclose(f);
|
||||
} else {
|
||||
/* fallback: not cryptographically strong but avoids hanging */
|
||||
srand((unsigned)time(NULL));
|
||||
for (size_t i = 0; i < sizeof(buf); i++)
|
||||
buf[i] = (unsigned char)rand();
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(buf); i++) {
|
||||
out[i * 2] = hex[buf[i] >> 4];
|
||||
out[i * 2 + 1] = hex[buf[i] & 0xf];
|
||||
}
|
||||
out[TOKEN_LEN] = '\0';
|
||||
}
|
||||
|
||||
const char *session_create(const char *username)
|
||||
{
|
||||
pthread_mutex_lock(&lock);
|
||||
|
||||
/* Find a free slot */
|
||||
for (int i = 0; i < MAX_SESSIONS; i++) {
|
||||
if (!sessions[i].active) {
|
||||
gen_token(sessions[i].token);
|
||||
strncpy(sessions[i].username, username, sizeof(sessions[i].username) - 1);
|
||||
sessions[i].username[sizeof(sessions[i].username) - 1] = '\0';
|
||||
sessions[i].active = 1;
|
||||
pthread_mutex_unlock(&lock);
|
||||
return sessions[i].token;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&lock);
|
||||
return NULL; /* no free slots */
|
||||
}
|
||||
|
||||
const char *session_lookup(const char *token)
|
||||
{
|
||||
if (!token || !*token) return NULL;
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
for (int i = 0; i < MAX_SESSIONS; i++) {
|
||||
if (sessions[i].active &&
|
||||
strncmp(sessions[i].token, token, TOKEN_LEN) == 0) {
|
||||
pthread_mutex_unlock(&lock);
|
||||
return sessions[i].username;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void session_destroy(const char *token)
|
||||
{
|
||||
if (!token || !*token) return;
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
for (int i = 0; i < MAX_SESSIONS; i++) {
|
||||
if (sessions[i].active &&
|
||||
strncmp(sessions[i].token, token, TOKEN_LEN) == 0) {
|
||||
sessions[i].active = 0;
|
||||
memset(sessions[i].token, 0, sizeof(sessions[i].token));
|
||||
memset(sessions[i].username, 0, sizeof(sessions[i].username));
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
13
auth/src/session.h
Normal file
13
auth/src/session.h
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#ifndef SESSION_H
|
||||
#define SESSION_H
|
||||
|
||||
/* Create a session for username — returns token string (valid until destroyed) */
|
||||
const char *session_create(const char *username);
|
||||
|
||||
/* Look up a token — returns username or NULL if not found */
|
||||
const char *session_lookup(const char *token);
|
||||
|
||||
/* Destroy a session */
|
||||
void session_destroy(const char *token);
|
||||
|
||||
#endif
|
||||
79
auth/src/user.c
Normal file
79
auth/src/user.c
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#include "user.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/*
|
||||
* Run a command with two arguments, writing optional stdin data to it.
|
||||
* Returns the exit code, or -1 on fork/exec failure.
|
||||
*/
|
||||
static int run_cmd(const char *path, const char *const argv[],
|
||||
const char *stdin_data)
|
||||
{
|
||||
int pipefd[2] = {-1, -1};
|
||||
|
||||
if (stdin_data) {
|
||||
if (pipe(pipefd) != 0) return -1;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
|
||||
if (pid == 0) {
|
||||
/* child */
|
||||
if (stdin_data) {
|
||||
close(pipefd[1]);
|
||||
dup2(pipefd[0], STDIN_FILENO);
|
||||
close(pipefd[0]);
|
||||
}
|
||||
execv(path, (char *const *)argv);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* parent */
|
||||
if (stdin_data) {
|
||||
close(pipefd[0]);
|
||||
size_t len = strlen(stdin_data);
|
||||
write(pipefd[1], stdin_data, len);
|
||||
close(pipefd[1]);
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
if (WIFEXITED(status)) return WEXITSTATUS(status);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int user_create(const char *username, const char *password)
|
||||
{
|
||||
/* Validate: username must be non-empty and only contain safe chars */
|
||||
if (!username || !*username || !password || !*password) return -1;
|
||||
for (const char *p = username; *p; p++) {
|
||||
char c = *p;
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-'))
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* useradd -m -s /bin/bash <username> */
|
||||
const char *useradd_argv[] = {
|
||||
"/usr/sbin/useradd", "-m", "-s", "/bin/bash", username, NULL
|
||||
};
|
||||
int rc = run_cmd("/usr/sbin/useradd", useradd_argv, NULL);
|
||||
if (rc != 0) return -1;
|
||||
|
||||
/* chpasswd reads "username:password\n" from stdin */
|
||||
char chpasswd_input[512];
|
||||
snprintf(chpasswd_input, sizeof(chpasswd_input), "%s:%s\n", username, password);
|
||||
|
||||
const char *chpasswd_argv[] = { "/usr/sbin/chpasswd", NULL };
|
||||
rc = run_cmd("/usr/sbin/chpasswd", chpasswd_argv, chpasswd_input);
|
||||
|
||||
/* Zero out the password from the stack before returning */
|
||||
memset(chpasswd_input, 0, sizeof(chpasswd_input));
|
||||
|
||||
return (rc == 0) ? 0 : -1;
|
||||
}
|
||||
10
auth/src/user.h
Normal file
10
auth/src/user.h
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#ifndef USER_H
|
||||
#define USER_H
|
||||
|
||||
/*
|
||||
* Create a new Linux user with useradd + set password via chpasswd.
|
||||
* Returns 0 on success, -1 on error (user already exists, permission denied, etc.)
|
||||
*/
|
||||
int user_create(const char *username, const char *password);
|
||||
|
||||
#endif
|
||||
|
|
@ -8,10 +8,35 @@
|
|||
<link rel="stylesheet" href="styles/window.css" />
|
||||
<link rel="stylesheet" href="styles/dock.css" />
|
||||
<link rel="stylesheet" href="styles/cursor.css" />
|
||||
<link rel="stylesheet" href="styles/login.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body x-data="os" @keydown.window="handleKeydown">
|
||||
|
||||
<!-- Login overlay (shown until authenticated) -->
|
||||
<div id="login-overlay" x-show="!authed" x-cloak>
|
||||
<div class="login-card">
|
||||
<h1>Crimata</h1>
|
||||
<input
|
||||
type="text"
|
||||
x-model="loginUsername"
|
||||
placeholder="Username"
|
||||
@keydown.enter="$refs.loginPassword.focus()"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
x-model="loginPassword"
|
||||
placeholder="Password"
|
||||
@keydown.enter="submitLogin"
|
||||
x-ref="loginPassword"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button @click="submitLogin">Sign in</button>
|
||||
<div class="login-error" x-text="loginError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infinite canvas -->
|
||||
<div id="canvas"
|
||||
@mousedown="startPan"
|
||||
|
|
@ -60,6 +85,7 @@
|
|||
<span class="dock-dot" x-show="app.running"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div id="dock-user" x-show="currentUser" x-text="currentUser"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/os.js"></script>
|
||||
|
|
|
|||
68
ui/js/os.js
68
ui/js/os.js
|
|
@ -1,6 +1,61 @@
|
|||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('os', () => ({
|
||||
|
||||
// ── Auth state ────────────────────────────────────────────────────────
|
||||
authed: false,
|
||||
currentUser: '',
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginError: '',
|
||||
|
||||
async submitLogin() {
|
||||
this.loginError = ''
|
||||
try {
|
||||
const res = await fetch('/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: this.loginUsername,
|
||||
password: this.loginPassword,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.success) {
|
||||
this.loginError = data.error || 'Login failed'
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem('auth_token', data.token)
|
||||
this.currentUser = data.username
|
||||
this.loginPassword = ''
|
||||
this.authed = true
|
||||
await this.loadApps()
|
||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
||||
if (bio) this.openApp(bio)
|
||||
} catch (e) {
|
||||
this.loginError = 'Auth service unreachable'
|
||||
}
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
const token = sessionStorage.getItem('auth_token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.currentUser = data.username
|
||||
this.authed = true
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
}
|
||||
} catch (_) {
|
||||
/* auth service down — leave authed=false */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Canvas pan state ──────────────────────────────────────────────────
|
||||
offset: { x: 0, y: 0 },
|
||||
isPanning: false,
|
||||
|
|
@ -105,13 +160,6 @@ document.addEventListener('alpine:init', () => {
|
|||
submitQuery() {
|
||||
const query = this.cursorQuery.trim().toLowerCase()
|
||||
|
||||
// Built-in commands
|
||||
if (query === 'login') {
|
||||
this.openApp({ id: 'login', name: 'Login', url: '/login', icon: '🔐' })
|
||||
this.exitInputMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches an installed app name
|
||||
const app = this.installedApps.find(a => a.name.toLowerCase() === query)
|
||||
if (app) { this.openApp(app); this.exitInputMode(); return }
|
||||
|
|
@ -139,15 +187,17 @@ document.addEventListener('alpine:init', () => {
|
|||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
async init() {
|
||||
await this.loadApps()
|
||||
await this.checkAuth()
|
||||
|
||||
if (this.authed) {
|
||||
await this.loadApps()
|
||||
// Refresh running status every 10s
|
||||
setInterval(() => this.loadApps(), 10_000)
|
||||
|
||||
// Open bio full-screen on load
|
||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
||||
if (bio) this.openApp(bio)
|
||||
}
|
||||
}
|
||||
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
|
|
|
|||
83
ui/styles/login.css
Normal file
83
ui/styles/login.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 40px 48px;
|
||||
width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.login-card input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.login-card input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #111;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.login-card button:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
font-size: 0.85rem;
|
||||
color: #ff6b6b;
|
||||
text-align: center;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
#dock-user {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
Loading…
Reference in a new issue