feat: UmbrelOS-style CE with iStore, Casdoor auth, and App Manager

- iStore service (port 3021): fetches app manifests from git.falahos.my/falahos
  org via Gitea API, 5-min cache, query/search support
- UmmahID login: replaced stub form with Casdoor OAuth2 PKCE flow (RFC 7636),
  no client secret required; /api/auth/config and /api/auth/token endpoints
  added to server.cjs
- App Manager service (port 3022): installs/uninstalls Docker apps via
  /var/run/docker.sock, pulls images, starts containers on falah-net,
  persists state to /data/installed.json
- docker-compose.yml: orchestrates falah-app + istore + app-manager with
  health checks, named volumes, and shared falah-net network
- install.sh: one-line curl installer — checks prereqs, clones repo,
  prompts for credentials, builds and starts all containers
- .env.example: all env vars documented (CASDOOR_CLIENT_ID, GITEA_TOKEN,
  APP_MANAGER_PORT, etc.)
- Frontend: IStore overlay now fetches live apps from Gitea, real
  install/uninstall via App Manager with 2s polling, indeterminate
  progress bar, Uninstall button for installed apps
- app.yml.example: developer manifest spec for publishing to iStore
This commit is contained in:
Antigravity AI
2026-07-06 10:05:37 +08:00
parent 8f1295f59d
commit 21b48d3d53
19 changed files with 1206 additions and 252 deletions
+23
View File
@@ -0,0 +1,23 @@
# ── Falah OS CE — Environment Variables ──────────────────────────────────────
# Copy this file to .env and fill in your values.
# Credentials are stored in bitwarden.falahos.my
#
# cp .env.example .env
# ── Ports (change only if defaults conflict on your host) ─────────────────────
CE_PORT=3005 # Falah OS CE frontend
ISTORE_PORT=3021 # iStore service
APP_MANAGER_PORT=3022 # App Manager (install/uninstall)
# ── UmmahID / Casdoor ─────────────────────────────────────────────────────────
# Casdoor application settings — create an app at auth.falahos.my
# and set redirect URI to http://<your-server-ip>:3005
CASDOOR_ENDPOINT=https://auth.falahos.my
CASDOOR_CLIENT_ID= # from Casdoor app settings (Client ID field)
# Note: no CASDOOR_CLIENT_SECRET needed — CE uses PKCE (RFC 7636)
# ── iStore / Gitea ────────────────────────────────────────────────────────────
# Personal access token from git.falahos.my/-/user/settings/applications
# Scopes required: read:org, read:repository
# Leave empty to use public repos only (rate-limited)
GITEA_TOKEN=
+90
View File
@@ -0,0 +1,90 @@
services:
# ── Falah OS CE — frontend + auth proxy ──────────────────────────────────
falah-app:
build:
context: .
dockerfile: Dockerfile
container_name: falah-app
restart: unless-stopped
ports:
- "${CE_PORT:-3005}:3000"
environment:
NODE_ENV: production
CASDOOR_ENDPOINT: ${CASDOOR_ENDPOINT:-https://auth.falahos.my}
CASDOOR_CLIENT_ID: ${CASDOOR_CLIENT_ID}
depends_on:
istore:
condition: service_healthy
networks:
- falah-net
labels:
falah.service: "falah-app"
falah.version: "1.3.0"
# ── iStore — Gitea-backed app registry ───────────────────────────────────
istore:
build:
context: ./services/istore
dockerfile: Dockerfile
container_name: falah-istore
restart: unless-stopped
ports:
- "${ISTORE_PORT:-3021}:3021"
environment:
NODE_ENV: production
PORT: 3021
GITEA_TOKEN: ${GITEA_TOKEN:-}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3021/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- falah-net
labels:
falah.service: "istore"
falah.version: "1.0.0"
# ── App Manager — install/uninstall Docker apps ──────────────────────────
app-manager:
build:
context: ./services/app-manager
dockerfile: Dockerfile
container_name: falah-app-manager
restart: unless-stopped
ports:
- "${APP_MANAGER_PORT:-3022}:3022"
environment:
NODE_ENV: production
PORT: 3022
ISTORE_URL: http://istore:3021
DOCKER_NETWORK: falah-net
DATA_DIR: /data
volumes:
- app-manager-data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
istore:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3022/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- falah-net
labels:
falah.service: "app-manager"
falah.version: "1.0.0"
volumes:
app-manager-data:
name: falah-app-manager-data
networks:
falah-net:
driver: bridge
name: falah-net
Executable
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# Falah OS CE — One-line installer
# Usage:
# curl -fsSL https://raw.githubusercontent.com/falahos/falah-os-ce/main/install.sh | bash
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
REPO_URL="https://github.com/falahos/falah-os-ce.git"
INSTALL_DIR="${FALAH_DIR:-$HOME/falah-os}"
CE_PORT="${CE_PORT:-3005}"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[falah]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
# ── Prerequisites ─────────────────────────────────────────────────────────────
check_cmd() { command -v "$1" &>/dev/null || die "$1 is required but not installed. Please install it first."; }
log "Checking prerequisites…"
check_cmd docker
check_cmd git
# Docker Compose v2 (plugin) or v1 (standalone)
if docker compose version &>/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE="docker-compose"
else
die "Docker Compose not found. Install it from https://docs.docker.com/compose/install/"
fi
# Confirm Docker daemon is running
docker info &>/dev/null || die "Docker daemon is not running. Start Docker and retry."
# ── Clone or update ───────────────────────────────────────────────────────────
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Updating existing installation at $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only
else
log "Installing Falah OS CE to $INSTALL_DIR"
git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
# ── Environment setup ─────────────────────────────────────────────────────────
if [[ ! -f .env ]]; then
cp .env.example .env
log "Created .env from template"
echo ""
warn "──────────────────────────────────────────────────"
warn " Configure your .env before starting Falah OS"
warn " credentials are in bitwarden.falahos.my"
warn "──────────────────────────────────────────────────"
echo ""
# Interactive prompts — only if running in a terminal
if [[ -t 0 ]]; then
read -rp " CASDOOR_CLIENT_ID (from auth.falahos.my): " casdoor_id
if [[ -n "$casdoor_id" ]]; then
sed -i.bak "s|^CASDOOR_CLIENT_ID=.*|CASDOOR_CLIENT_ID=$casdoor_id|" .env && rm -f .env.bak
fi
read -rp " GITEA_TOKEN (from git.falahos.my, optional — press Enter to skip): " gitea_token
if [[ -n "$gitea_token" ]]; then
sed -i.bak "s|^GITEA_TOKEN=.*|GITEA_TOKEN=$gitea_token|" .env && rm -f .env.bak
fi
else
warn "Non-interactive mode — edit $INSTALL_DIR/.env manually then run:"
warn " cd $INSTALL_DIR && $COMPOSE up -d"
exit 0
fi
fi
# ── Build & start ─────────────────────────────────────────────────────────────
log "Building containers (this takes a minute on first run)…"
$COMPOSE build --parallel
log "Starting Falah OS CE…"
$COMPOSE up -d
# ── Health check ──────────────────────────────────────────────────────────────
log "Waiting for services to be ready…"
for i in $(seq 1 20); do
if curl -sf "http://localhost:${CE_PORT}/health" &>/dev/null; then
break
fi
if [[ $i -eq 20 ]]; then
warn "Falah OS CE did not respond in time. Check logs with:"
warn " cd $INSTALL_DIR && $COMPOSE logs -f"
exit 1
fi
sleep 3
done
# ── Detect server IP for display ──────────────────────────────────────────────
SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} ☽ Falah OS CE is running!${NC}"
echo ""
echo -e " Browser → http://${SERVER_IP}:${CE_PORT}"
echo ""
echo -e " Manage → cd ${INSTALL_DIR}"
echo -e " $COMPOSE logs -f (live logs)"
echo -e " $COMPOSE down (stop)"
echo -e " $COMPOSE pull && $COMPOSE up -d (update)"
echo ""
echo -e " Casdoor redirect URI to register:"
echo -e " http://${SERVER_IP}:${CE_PORT}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
-39
View File
@@ -897,9 +897,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -914,9 +911,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -931,9 +925,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -948,9 +939,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -965,9 +953,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -982,9 +967,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -999,9 +981,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1016,9 +995,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1033,9 +1009,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1050,9 +1023,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1067,9 +1037,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1084,9 +1051,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1101,9 +1065,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+123 -5
View File
@@ -1,22 +1,140 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
const CASDOOR_ENDPOINT = process.env.CASDOOR_ENDPOINT || 'https://auth.falahos.my'
const CASDOOR_CLIENT_ID = process.env.CASDOOR_CLIENT_ID || ''
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'dist')));
// AWS-level standard: proper static file serving with cache control
const distPath = path.join(__dirname, 'dist');
if (!fs.existsSync(distPath)) {
console.warn(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', service: 'app', msg: 'dist directory does not exist! The frontend will not be served.' }));
}
app.use(express.static(distPath, {
maxAge: '1d', // Cache static assets for performance
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
// Don't cache HTML to ensure users get the latest version on reload
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}
}));
// ── UmmahID / Casdoor auth ────────────────────────────────────────────────
function decodeJwtPayload(token) {
try {
const payload = token.split('.')[1]
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'))
} catch {
return {}
}
}
// Returns safe public config — no secrets exposed
app.get('/api/auth/config', (_req, res) => {
if (!CASDOOR_CLIENT_ID) {
return res.status(503).json({ error: 'CASDOOR_CLIENT_ID not configured on server' })
}
res.json({
clientId: CASDOOR_CLIENT_ID,
endpoint: CASDOOR_ENDPOINT,
authorizePath: '/login/oauth/authorize',
})
})
// PKCE token exchange — code_verifier replaces client_secret for public clients
app.post('/api/auth/token', async (req, res) => {
const { code, codeVerifier, redirectUri } = req.body
if (!code || !codeVerifier || !redirectUri) {
return res.status(400).json({ error: 'Missing code, codeVerifier, or redirectUri' })
}
try {
const tokenRes = await fetch(`${CASDOOR_ENDPOINT}/api/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: CASDOOR_CLIENT_ID,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
})
const data = await tokenRes.json()
if (!tokenRes.ok || data.error) {
return res.status(400).json({ error: data.error_description || data.error || 'Token exchange failed' })
}
const claims = decodeJwtPayload(data.id_token || data.access_token)
res.json({
accessToken: data.access_token,
user: {
id: claims.sub || claims.id || '',
email: claims.email || '',
fullName: claims.name || claims.preferred_username || '',
role: claims['falah/role'] || claims.role || 'user',
},
})
} catch (err) {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'auth', msg: err.message }))
res.status(502).json({ error: 'Could not reach Casdoor' })
}
})
// ─────────────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({ status: 'healthy', service: 'app', version: '1.3.0' });
res.json({ status: 'healthy', service: 'app', version: '1.3.0', timestamp: new Date().toISOString() });
});
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
app.get('*', (req, res, next) => {
const indexPath = path.join(distPath, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath, (err) => {
if (err) {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Error sending index.html', error: err.message }));
next(err); // Pass to global error handler
}
});
} else {
res.status(404).send('Not Found: Application build missing.');
}
});
app.listen(PORT, () => {
// Global Error Handler
app.use((err, req, res, next) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Unhandled error in Express', error: err.stack || err.message }));
if (!res.headersSent) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
const server = app.listen(PORT, () => {
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `running on port ${PORT}` }));
});
// Graceful Shutdown for DevOps / Orchestrators (ECS, K8s, Cloud Run)
const shutdown = (signal) => {
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `Received ${signal}. Shutting down gracefully...` }));
server.close(() => {
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: 'Closed remaining connections.' }));
process.exit(0);
});
// Force shutdown after 10s if connections linger
setTimeout(() => {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Could not close connections in time, forcefully shutting down' }));
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.cjs ./
RUN mkdir -p /data
VOLUME ["/data"]
EXPOSE 3022
ENV NODE_ENV=production
ENV DATA_DIR=/data
CMD ["node", "server.cjs"]
+17
View File
@@ -0,0 +1,17 @@
{
"name": "falah-app-manager",
"version": "1.0.0",
"description": "Falah OS CE — App Manager (install/uninstall Docker apps)",
"main": "server.cjs",
"scripts": {
"start": "node server.cjs"
},
"dependencies": {
"cors": "^2.8.5",
"dockerode": "^4.0.2",
"express": "^4.18.2"
},
"engines": {
"node": ">=18"
}
}
+210
View File
@@ -0,0 +1,210 @@
'use strict'
const express = require('express')
const cors = require('cors')
const Docker = require('dockerode')
const fs = require('fs')
const path = require('path')
const app = express()
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
app.use(cors())
app.use(express.json())
const PORT = process.env.PORT || 3022
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const ISTORE_URL = process.env.ISTORE_URL || 'http://istore:3021'
const DOCKER_NETWORK = process.env.DOCKER_NETWORK || 'falah-net'
const DB_FILE = path.join(DATA_DIR, 'installed.json')
// ── Persistence (JSON file) ───────────────────────────────────────────────────
function readDb() {
try {
if (!fs.existsSync(DB_FILE)) return {}
return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'))
} catch {
return {}
}
}
function writeDb(data) {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2))
}
function getApp(id) { return readDb()[id] || null }
function upsertApp(id, fields) {
const db = readDb()
db[id] = { ...(db[id] || {}), id, ...fields, updatedAt: new Date().toISOString() }
writeDb(db)
return db[id]
}
function removeApp(id) {
const db = readDb()
delete db[id]
writeDb(db)
}
// ── Docker helpers ────────────────────────────────────────────────────────────
const CONTAINER_PREFIX = 'falah-app-'
function containerName(id) { return `${CONTAINER_PREFIX}${id}` }
async function pullImage(image) {
return new Promise((resolve, reject) => {
docker.pull(image, (err, stream) => {
if (err) return reject(err)
docker.modem.followProgress(stream, (err2) => {
if (err2) reject(err2)
else resolve()
})
})
})
}
async function startContainer(id, image, port) {
const name = containerName(id)
// Remove any existing stopped container with the same name
try {
const old = docker.getContainer(name)
await old.remove({ force: true })
} catch { /* not found — fine */ }
const portSpec = port ? { [`${port}/tcp`]: [{ HostPort: String(port) }] } : {}
const container = await docker.createContainer({
name,
Image: image,
RestartPolicy: { Name: 'unless-stopped' },
HostConfig: {
PortBindings: portSpec,
NetworkMode: DOCKER_NETWORK,
},
ExposedPorts: port ? { [`${port}/tcp`]: {} } : {},
Labels: { 'falah.managed': 'true', 'falah.app': id },
})
await container.start()
return container.id
}
async function stopAndRemoveContainer(id) {
const name = containerName(id)
try {
const container = docker.getContainer(name)
const info = await container.inspect()
if (info.State.Running) await container.stop()
await container.remove()
} catch (err) {
if (!err.message?.includes('No such container')) throw err
}
}
async function containerStatus(id) {
try {
const info = await docker.getContainer(containerName(id)).inspect()
return info.State.Running ? 'running' : 'stopped'
} catch {
return 'not_found'
}
}
// ── Install worker (async, non-blocking) ──────────────────────────────────────
async function installApp(id, manifest) {
const { image, port, name, version } = manifest
try {
upsertApp(id, { name, version, image, port, status: 'pulling', error: null })
await pullImage(image)
upsertApp(id, { status: 'starting' })
const containerId = await startContainer(id, image, port)
upsertApp(id, { status: 'running', containerId, installedAt: new Date().toISOString() })
} catch (err) {
upsertApp(id, { status: 'error', error: err.message })
}
}
// ── Routes ────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ ok: true }))
app.get('/api/installed', (_req, res) => {
res.json({ apps: Object.values(readDb()) })
})
app.get('/api/status/:id', (req, res) => {
const record = getApp(req.params.id)
if (!record) return res.json({ id: req.params.id, status: 'not_installed' })
res.json(record)
})
app.post('/api/install/:id', async (req, res) => {
const { id } = req.params
const existing = getApp(id)
if (existing && ['pulling', 'starting'].includes(existing.status)) {
return res.status(409).json({ error: 'Install already in progress' })
}
if (existing?.status === 'running') {
return res.status(409).json({ error: 'Already installed' })
}
// Fetch manifest from iStore service
let manifest
try {
const r = await fetch(`${ISTORE_URL}/api/apps/${id}`)
if (!r.ok) return res.status(404).json({ error: 'App not found in iStore' })
manifest = await r.json()
} catch {
return res.status(502).json({ error: 'Cannot reach iStore service' })
}
if (!manifest.image) {
return res.status(400).json({ error: 'App has no Docker image defined in manifest' })
}
// Start install in background — respond immediately
upsertApp(id, { status: 'pending', name: manifest.name, image: manifest.image })
installApp(id, manifest).catch(console.error)
res.status(202).json({ id, status: 'pending', message: 'Installation started' })
})
app.delete('/api/uninstall/:id', async (req, res) => {
const { id } = req.params
const record = getApp(id)
if (!record) return res.status(404).json({ error: 'App not installed' })
try {
await stopAndRemoveContainer(id)
removeApp(id)
res.json({ id, status: 'uninstalled' })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Sync container state with Docker reality (e.g. after CE restarts)
app.post('/api/sync', async (_req, res) => {
const db = readDb()
for (const [id, record] of Object.entries(db)) {
if (record.status === 'running') {
const live = await containerStatus(id)
if (live !== 'running') {
upsertApp(id, { status: live === 'stopped' ? 'stopped' : 'error' })
}
}
}
res.json({ synced: Object.keys(db).length })
})
app.listen(PORT, () =>
console.log(`[app-manager] running → http://localhost:${PORT} | Docker network: ${DOCKER_NETWORK}`)
)
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.cjs ./
EXPOSE 3021
ENV NODE_ENV=production
CMD ["node", "server.cjs"]
+26
View File
@@ -0,0 +1,26 @@
# Falah OS iStore — App Manifest
# Place this file as `app.yml` at the root of your Gitea repo under the `falahos` org.
# The iStore service reads this to list and describe your app.
# Required fields
id: my-app-id # Unique slug, kebab-case, no spaces (e.g. zakat-vault)
name: My App Name # Display name shown in iStore
version: 1.0.0 # Semver
tagline: One-line description of what this app does
category: Finance # Finance | Charity | Worship | Identity | Community | Contracts | Compliance | Dev Tools | Legal | Insurance | Payments | DeFi | Investment
# Shariah compliance
ramz_verified: true # true = RAMZ-screened, false = community-submitted (pending review)
# Docker
image: falahapps/my-app:1.0.0 # Docker Hub image (or registry URL)
port: 3030 # Container port this app listens on
# Optional display
icon: 📦 # Emoji icon shown in iStore and desktop
featured: false # true = shown in Featured section on iStore homepage
# Metadata
author: Falah Consultancy Limited
license: MIT
min_ce_version: 1.3.0 # Minimum Falah OS CE version required
+16
View File
@@ -0,0 +1,16 @@
{
"name": "falah-istore",
"version": "1.0.0",
"description": "Falah OS iStore — Gitea-backed app registry service",
"main": "server.cjs",
"scripts": {
"start": "node server.cjs"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2"
},
"engines": {
"node": ">=18"
}
}
+153
View File
@@ -0,0 +1,153 @@
'use strict'
const express = require('express')
const cors = require('cors')
const app = express()
app.use(cors())
app.use(express.json())
const GITEA_BASE = 'https://git.falahos.my/api/v1'
const GITEA_ORG = 'falahos'
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
const PORT = process.env.PORT || 3021
const CACHE_TTL = 5 * 60 * 1000
let _cache = null
let _cacheTs = 0
function giteaHeaders() {
const h = { Accept: 'application/json' }
if (GITEA_TOKEN) h['Authorization'] = `token ${GITEA_TOKEN}`
return h
}
// Minimal flat YAML parser — covers the app.yml format only
function parseAppYml(text) {
const result = {}
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const colonIdx = trimmed.indexOf(':')
if (colonIdx === -1) continue
const key = trimmed.slice(0, colonIdx).trim()
const raw = trimmed.slice(colonIdx + 1).trim()
if (raw === 'true') result[key] = true
else if (raw === 'false') result[key] = false
else if (raw !== '' && !isNaN(Number(raw))) result[key] = Number(raw)
else result[key] = raw.replace(/^["']|["']$/g, '')
}
return result
}
async function fetchAllApps() {
if (_cache && Date.now() - _cacheTs < CACHE_TTL) return _cache
// Paginate org repos
let page = 1
let repos = []
while (true) {
const res = await fetch(
`${GITEA_BASE}/orgs/${GITEA_ORG}/repos?limit=50&page=${page}`,
{ headers: giteaHeaders() }
)
if (!res.ok) break
const batch = await res.json()
if (!Array.isArray(batch) || !batch.length) break
repos = repos.concat(batch)
if (batch.length < 50) break
page++
}
// Read app.yml from each repo concurrently
const settled = await Promise.allSettled(
repos.map(async (repo) => {
const res = await fetch(
`${GITEA_BASE}/repos/${GITEA_ORG}/${repo.name}/contents/app.yml`,
{ headers: giteaHeaders() }
)
if (!res.ok) return null
const data = await res.json()
if (!data.content) return null
const yaml = Buffer.from(data.content, 'base64').toString('utf-8')
const m = parseAppYml(yaml)
if (!m.id || !m.name) return null
return {
id: String(m.id),
name: String(m.name),
icon: String(m.icon || '📦'),
iconClass: `icon-${m.id}`,
tagline: String(m.tagline || ''),
category: String(m.category || 'Other'),
ramzVerified: m.ramz_verified === true,
installed: false,
featured: m.featured === true,
version: String(m.version || '1.0.0'),
port: m.port ? Number(m.port) : null,
image: m.image ? String(m.image) : null,
author: String(m.author || ''),
license: String(m.license || ''),
minCeVersion: String(m.min_ce_version || '1.0.0'),
repoUrl: repo.html_url,
updatedAt: repo.updated_at,
}
})
)
const apps = settled
.filter(r => r.status === 'fulfilled' && r.value !== null)
.map(r => r.value)
_cache = apps
_cacheTs = Date.now()
return apps
}
app.get('/health', (_req, res) => res.json({ ok: true }))
app.get('/api/apps', async (req, res) => {
try {
const apps = await fetchAllApps()
const { category, search, featured } = req.query
let filtered = apps
if (category && category !== 'All') filtered = filtered.filter(a => a.category === category)
if (search) {
const q = String(search).toLowerCase()
filtered = filtered.filter(a =>
a.name.toLowerCase().includes(q) || a.tagline.toLowerCase().includes(q)
)
}
if (featured === 'true') filtered = filtered.filter(a => a.featured)
res.json({ apps: filtered, total: filtered.length, cachedAt: _cacheTs })
} catch (err) {
console.error('[istore] fetchAllApps error:', err)
res.status(502).json({ error: 'Failed to reach Gitea', detail: err.message })
}
})
app.get('/api/apps/:id', async (req, res) => {
try {
const apps = await fetchAllApps()
const found = apps.find(a => a.id === req.params.id)
if (!found) return res.status(404).json({ error: 'App not found' })
res.json(found)
} catch (err) {
res.status(502).json({ error: err.message })
}
})
// Force cache refresh
app.post('/api/cache/clear', (_req, res) => {
_cache = null
_cacheTs = 0
res.json({ ok: true })
})
app.listen(PORT, () =>
console.log(`[istore] running → http://localhost:${PORT} | org: ${GITEA_ORG} @ ${GITEA_BASE}`)
)
+40 -3
View File
@@ -1,16 +1,44 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useApp } from './store'
import Onboarding from './screens/Onboarding'
import Login from './screens/Login'
import Desktop from './screens/Desktop'
import { handleOAuthCallback } from './hooks/useAuth'
import type { AuthUser } from './types'
export default function App() {
const { screen, wallpaper, closeOverlay } = useApp()
const { screen, wallpaper, closeOverlay, setAuth, navigate } = useApp()
const [callbackPending, setCallbackPending] = useState(false)
// Handle Casdoor OAuth2 PKCE callback (?code=... in URL)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
if (!code) return
// Clean the URL immediately so refresh doesn't re-trigger
window.history.replaceState({}, '', window.location.pathname)
setCallbackPending(true)
handleOAuthCallback(code)
.then(result => {
if (!result) return
const user: AuthUser = {
id: result.user.id,
email: result.user.email,
fullName: result.user.fullName,
role: result.user.role,
}
setAuth(result.accessToken, user)
navigate('desktop')
})
.catch(console.error)
.finally(() => setCallbackPending(false))
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeOverlay()
// '/' is handled inside Desktop
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
@@ -26,6 +54,15 @@ export default function App() {
transition: 'opacity 0.4s, transform 0.4s',
})
if (callbackPending) {
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#07090c', gap: '16px' }}>
<div style={{ fontSize: '32px' }}></div>
<div style={{ color: 'var(--text-muted)', fontSize: '14px' }}>Signing you in</div>
</div>
)
}
return (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<div className={wallpaperClass} />
+2
View File
@@ -14,6 +14,8 @@ export const API = {
qurban: `http://${host}:3016`,
sadaqah: `http://${host}:3014`,
infaq: `http://${host}:3015`,
istore: `http://${host}:3021`,
appManager: `http://${host}:3022`,
}
export async function apiFetch<T>(url: string, token?: string | null, opts?: RequestInit): Promise<T> {
+87
View File
@@ -0,0 +1,87 @@
import type { AuthUser } from '../types'
const VERIFIER_KEY = 'falah_pkce_verifier'
const STATE_KEY = 'falah_pkce_state'
interface AuthConfig {
clientId: string
endpoint: string
authorizePath: string
}
interface TokenResult {
accessToken: string
user: AuthUser
}
// ── PKCE primitives ──────────────────────────────────────────────────────────
function base64urlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
function generateVerifier(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return base64urlEncode(bytes)
}
async function computeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return base64urlEncode(new Uint8Array(digest))
}
// ── Public API ───────────────────────────────────────────────────────────────
export async function fetchAuthConfig(): Promise<AuthConfig> {
const res = await fetch('/api/auth/config')
if (!res.ok) throw new Error('Auth not configured on server')
return res.json() as Promise<AuthConfig>
}
export async function initiateLogin(config: AuthConfig): Promise<void> {
const verifier = generateVerifier()
const challenge = await computeChallenge(verifier)
const state = generateVerifier()
sessionStorage.setItem(VERIFIER_KEY, verifier)
sessionStorage.setItem(STATE_KEY, state)
const redirectUri = window.location.origin
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
state,
})
window.location.href = `${config.endpoint}${config.authorizePath}?${params}`
}
export async function handleOAuthCallback(code: string): Promise<TokenResult | null> {
const verifier = sessionStorage.getItem(VERIFIER_KEY)
if (!verifier) return null
sessionStorage.removeItem(VERIFIER_KEY)
sessionStorage.removeItem(STATE_KEY)
const res = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
codeVerifier: verifier,
redirectUri: window.location.origin,
}),
})
if (!res.ok) return null
return res.json() as Promise<TokenResult>
}
+6
View File
@@ -185,3 +185,9 @@ input { font-family: inherit; border: none; outline: none; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 99px; }
* { scrollbar-width: thin; scrollbar-color: var(--glass-border) transparent; }
@keyframes slide {
0% { transform: translateX(-100%); }
50% { transform: translateX(150%); }
100% { transform: translateX(150%); }
}
+182 -106
View File
@@ -1,11 +1,16 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useApp } from '../store'
import { STORE_APPS, FEATURED_APPS, CATEGORIES } from '../data/apps'
import { STORE_APPS, FEATURED_APPS } from '../data/apps'
import { API } from '../hooks/useApi'
import type { FalahApp } from '../types'
const FEATURED_APP_DATA: FalahApp[] = STORE_APPS.filter(a =>
FEATURED_APPS.includes(a.id)
)
type InstallStatus = 'not_installed' | 'pending' | 'pulling' | 'starting' | 'running' | 'stopped' | 'error'
interface AppRecord {
id: string
status: InstallStatus
error?: string
}
const FEATURED_GRADIENTS: Record<string, string> = {
'zakat-vault': 'linear-gradient(135deg, #DC2626, #7F1D1D)',
@@ -17,13 +22,76 @@ export default function IStore() {
const { overlay, closeOverlay } = useApp()
const isOpen = overlay === 'istore'
const [liveApps, setLiveApps] = useState<FalahApp[]>([])
const [loadingApps, setLoadingApps] = useState(false)
const [liveError, setLiveError] = useState(false)
const [activeCategory, setActiveCategory] = useState('All')
const [installed, setInstalled] = useState<Set<string>>(new Set())
const [progress, setProgress] = useState<Record<string, number>>({})
const [appStatuses, setAppStatuses] = useState<Record<string, AppRecord>>({})
const [searchQuery, setSearchQuery] = useState('')
const timerRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({})
const pollRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({})
const filteredApps = STORE_APPS.filter(app => {
// Fetch live app list from iStore service (backed by Gitea) when overlay opens
useEffect(() => {
if (!isOpen) return
setLoadingApps(true)
setLiveError(false)
fetch(`${API.istore}/api/apps`)
.then(r => r.json())
.then(data => {
if (Array.isArray(data.apps) && data.apps.length > 0) setLiveApps(data.apps)
})
.catch(() => setLiveError(true))
.finally(() => setLoadingApps(false))
}, [isOpen])
// Load installed state from App Manager when overlay opens
useEffect(() => {
if (!isOpen) return
fetch(`${API.appManager}/api/installed`)
.then(r => r.json())
.then(data => {
if (!Array.isArray(data.apps)) return
const map: Record<string, AppRecord> = {}
data.apps.forEach((a: AppRecord) => { map[a.id] = a })
setAppStatuses(map)
})
.catch(() => { /* app-manager offline — silent */ })
}, [isOpen])
// Poll install status for in-progress apps
const startPolling = useCallback((id: string) => {
if (pollRefs.current[id]) return
pollRefs.current[id] = setInterval(async () => {
try {
const r = await fetch(`${API.appManager}/api/status/${id}`)
const data: AppRecord = await r.json()
setAppStatuses(prev => ({ ...prev, [id]: data }))
if (data.status === 'running' || data.status === 'error' || data.status === 'not_installed') {
clearInterval(pollRefs.current[id])
delete pollRefs.current[id]
}
} catch {
clearInterval(pollRefs.current[id])
delete pollRefs.current[id]
}
}, 2000)
}, [])
useEffect(() => {
return () => { Object.values(pollRefs.current).forEach(clearInterval) }
}, [])
// Live apps from Gitea take priority; fall back to bundled STORE_APPS when offline
const allApps = liveApps.length > 0 ? liveApps : STORE_APPS
const featuredApps = allApps.filter(a =>
a.featured || FEATURED_APPS.includes(a.id)
)
// Build category list dynamically from whatever apps are loaded
const categories = ['All', ...Array.from(new Set(allApps.map(a => a.category))).sort()]
const filteredApps = allApps.filter(app => {
const matchesCat = activeCategory === 'All' || app.category === activeCategory
const matchesSearch =
searchQuery === '' ||
@@ -32,33 +100,32 @@ export default function IStore() {
return matchesCat && matchesSearch
})
function handleInstall(appId: string) {
if (installed.has(appId) || progress[appId] !== undefined) return
setProgress(prev => ({ ...prev, [appId]: 0 }))
let pct = 0
const interval = setInterval(() => {
pct += 100 / 15
if (pct >= 100) {
clearInterval(interval)
delete timerRefs.current[appId]
setProgress(prev => {
const next = { ...prev }
delete next[appId]
return next
})
setInstalled(prev => new Set(prev).add(appId))
} else {
setProgress(prev => ({ ...prev, [appId]: Math.min(Math.round(pct), 99) }))
}
}, 100)
timerRefs.current[appId] = interval
async function handleInstall(appId: string) {
const current = appStatuses[appId]?.status
if (current === 'running' || current === 'pulling' || current === 'starting' || current === 'pending') return
setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'pending' } }))
try {
const r = await fetch(`${API.appManager}/api/install/${appId}`, { method: 'POST' })
const data: AppRecord = await r.json()
setAppStatuses(prev => ({ ...prev, [appId]: data }))
if (data.status !== 'running') startPolling(appId)
} catch {
setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'error', error: 'App Manager unreachable' } }))
}
}
useEffect(() => {
return () => {
Object.values(timerRefs.current).forEach(clearInterval)
}
}, [])
async function handleUninstall(appId: string) {
try {
await fetch(`${API.appManager}/api/uninstall/${appId}`, { method: 'DELETE' })
setAppStatuses(prev => {
const next = { ...prev }
delete next[appId]
return next
})
} catch { /* silent */ }
}
return (
<div
@@ -143,6 +210,21 @@ export default function IStore() {
</button>
</div>
{/* Live / fallback status strip */}
{(liveError || loadingApps) && (
<div style={{
padding: '6px 32px',
fontSize: '11px',
color: liveError ? '#F59E0B' : 'var(--text-muted)',
background: liveError ? 'rgba(245,158,11,0.06)' : 'transparent',
borderBottom: '1px solid rgba(255,255,255,0.04)',
flexShrink: 0,
}}>
{loadingApps && '⟳ Loading apps from git.falahos.my…'}
{liveError && '⚠ Could not reach iStore service — showing cached apps'}
</div>
)}
{/* Category chips */}
<div
style={{
@@ -154,7 +236,7 @@ export default function IStore() {
flexShrink: 0,
}}
>
{CATEGORIES.map(cat => (
{categories.map(cat => (
<button
key={cat}
className={`cat-chip${activeCategory === cat ? ' active' : ''}`}
@@ -213,7 +295,7 @@ export default function IStore() {
</div>
{/* Featured section — only when showing All and no search filter */}
{activeCategory === 'All' && searchQuery === '' && (
{activeCategory === 'All' && searchQuery === '' && featuredApps.length > 0 && (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>Featured</h3>
@@ -227,8 +309,8 @@ export default function IStore() {
gap: '14px',
}}
>
{FEATURED_APP_DATA.map(app => (
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? ''} />
{featuredApps.map(app => (
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? 'linear-gradient(135deg, #10B981, #059669)'} />
))}
</div>
</div>
@@ -241,8 +323,13 @@ export default function IStore() {
{activeCategory === 'All' ? 'All Apps' : activeCategory}
</h3>
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
{filteredApps.length} apps
{filteredApps.length} app{filteredApps.length !== 1 ? 's' : ''}
</span>
{liveApps.length > 0 && (
<span style={{ color: 'var(--brand)', fontSize: '11px', marginLeft: 'auto' }}>
live from git.falahos.my
</span>
)}
</div>
<div
@@ -256,9 +343,10 @@ export default function IStore() {
<AppRow
key={app.id}
app={app}
isInstalled={installed.has(app.id)}
installProgress={progress[app.id]}
status={appStatuses[app.id]?.status ?? 'not_installed'}
installError={appStatuses[app.id]?.error}
onInstall={() => handleInstall(app.id)}
onUninstall={() => handleUninstall(app.id)}
/>
))}
</div>
@@ -349,18 +437,34 @@ function FeaturedCard({ app, gradient }: { app: FalahApp; gradient: string }) {
)
}
const STATUS_LABEL: Record<InstallStatus, string> = {
not_installed: 'Install',
pending: 'Queued…',
pulling: 'Pulling…',
starting: 'Starting…',
running: 'Installed',
stopped: 'Stopped',
error: 'Retry',
}
const IN_PROGRESS: InstallStatus[] = ['pending', 'pulling', 'starting']
function AppRow({
app,
isInstalled,
installProgress,
status,
installError,
onInstall,
onUninstall,
}: {
app: FalahApp
isInstalled: boolean
installProgress: number | undefined
status: InstallStatus
installError?: string
onInstall: () => void
onUninstall: () => void
}) {
const [hovered, setHovered] = useState(false)
const inProgress = IN_PROGRESS.includes(status)
const isRunning = status === 'running'
return (
<div
@@ -396,28 +500,16 @@ function AppRow({
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{app.name}
{app.version && (
<span style={{ fontSize: '10px', color: 'var(--text-dim)', fontWeight: 400, marginLeft: '5px' }}>
v{app.version}
</span>
)}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{app.tagline}
<div style={{ fontSize: '11px', color: status === 'error' ? '#F87171' : 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{status === 'error' ? (installError || 'Install failed') : app.tagline}
</div>
{app.ramzVerified && (
<div className="ramz-badge">
@@ -427,50 +519,34 @@ function AppRow({
)}
</div>
{/* Install button */}
<div style={{ flexShrink: 0 }}>
{installProgress !== undefined ? (
<div
style={{
width: '64px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
alignItems: 'center',
}}
>
<div
style={{
width: '100%',
height: '3px',
borderRadius: '99px',
background: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${installProgress}%`,
background: 'var(--brand)',
borderRadius: '99px',
transition: 'width 0.1s linear',
}}
/>
{/* Action */}
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
{inProgress ? (
<div style={{ width: '64px', display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
{/* Indeterminate progress bar */}
<div style={{ width: '100%', height: '3px', borderRadius: '99px', background: 'rgba(255,255,255,0.1)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: '40%', background: 'var(--brand)', borderRadius: '99px', animation: 'slide 1.2s ease-in-out infinite' }} />
</div>
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{installProgress}%</span>
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{STATUS_LABEL[status]}</span>
</div>
) : (
<button
className={`btn-install${isInstalled ? ' btn-installed' : ''}`}
onClick={e => {
e.stopPropagation()
onInstall()
}}
disabled={isInstalled}
>
{isInstalled ? 'Installed' : 'Install'}
</button>
<>
<button
className={`btn-install${isRunning ? ' btn-installed' : ''}`}
onClick={e => { e.stopPropagation(); if (!isRunning) onInstall() }}
disabled={isRunning}
>
{STATUS_LABEL[status]}
</button>
{isRunning && (
<button
onClick={e => { e.stopPropagation(); onUninstall() }}
style={{ fontSize: '10px', color: 'var(--text-dim)', background: 'none', border: 'none', cursor: 'pointer', padding: '0' }}
>
Uninstall
</button>
)}
</>
)}
</div>
</div>
+69 -99
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useApp } from '../store'
import { useState, useCallback } from 'react'
import { fetchAuthConfig, initiateLogin } from '../hooks/useAuth'
function Logo({ size }: { size: number }) {
return (
@@ -25,37 +25,21 @@ function Logo({ size }: { size: number }) {
}
export default function Login() {
const { navigate } = useApp()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [emailFocused, setEmailFocused] = useState(false)
const [passwordFocused, setPasswordFocused] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const inputStyle = (focused: boolean): React.CSSProperties => ({
width: '100%',
padding: '12px 16px',
background: focused ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${focused ? 'var(--brand)' : 'var(--glass-border)'}`,
borderRadius: 8,
color: 'var(--text)',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.15s, background 0.15s',
})
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 11,
fontWeight: 600,
color: 'var(--text-muted)',
letterSpacing: 1,
marginBottom: 6,
}
const handleSignIn = (e: React.FormEvent) => {
e.preventDefault()
navigate('desktop')
}
const handleSignIn = useCallback(async () => {
setLoading(true)
setError('')
try {
const config = await fetchAuthConfig()
await initiateLogin(config)
// page will redirect — no further state updates needed
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not reach auth server')
setLoading(false)
}
}, [])
return (
<div
@@ -68,8 +52,7 @@ export default function Login() {
padding: 24,
}}
>
<form
onSubmit={handleSignIn}
<div
style={{
width: '100%',
maxWidth: 400,
@@ -83,7 +66,6 @@ export default function Login() {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0,
}}
>
{/* Arabic */}
@@ -100,83 +82,71 @@ export default function Login() {
بِسْمِ اللَّهِ
</div>
{/* Logo */}
<Logo size={72} />
{/* Title */}
<h1
style={{
fontSize: 26,
fontWeight: 700,
color: 'var(--text)',
margin: '16px 0 4px',
}}
>
<h1 style={{ fontSize: 26, fontWeight: 700, color: 'var(--text)', margin: '16px 0 4px' }}>
Falah OS
</h1>
{/* Subtitle */}
<p
style={{
fontSize: 13,
color: 'var(--text-muted)',
margin: '0 0 32px',
}}
>
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 36px', textAlign: 'center' }}>
Sovereign Digital Economy
</p>
{/* Email field */}
<div style={{ width: '100%', marginBottom: 16 }}>
<label style={labelStyle}>EMAIL</label>
<input
type="email"
placeholder="operator@falah.os"
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => setEmailFocused(true)}
onBlur={() => setEmailFocused(false)}
style={inputStyle(emailFocused)}
autoComplete="email"
/>
</div>
{/* Password field */}
<div style={{ width: '100%', marginBottom: 28 }}>
<label style={labelStyle}>PASSWORD</label>
<input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setPasswordFocused(true)}
onBlur={() => setPasswordFocused(false)}
style={inputStyle(passwordFocused)}
autoComplete="current-password"
/>
</div>
{/* Sign In button */}
<button
type="submit"
className="btn-primary"
style={{ width: '100%', marginBottom: 24 }}
>
Sign In
</button>
{/* Footer */}
<p
{/* UmmahID sign-in block */}
<div
style={{
fontSize: 11,
color: 'var(--text-dim)',
textAlign: 'center',
margin: 0,
width: '100%',
background: 'rgba(16,185,129,0.06)',
border: '1px solid rgba(16,185,129,0.18)',
borderRadius: 16,
padding: '20px 20px 24px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
marginBottom: 24,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 22 }}>🪪</span>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>UmmahID</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>Zero-knowledge identity · auth.falahos.my</div>
</div>
</div>
<button
onClick={handleSignIn}
disabled={loading}
className="btn-primary"
style={{ width: '100%', marginTop: 4, opacity: loading ? 0.6 : 1 }}
>
{loading ? 'Redirecting to UmmahID…' : 'Sign in with UmmahID'}
</button>
</div>
{error && (
<div
style={{
width: '100%',
fontSize: 12,
color: '#F87171',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
borderRadius: 8,
padding: '10px 14px',
textAlign: 'center',
marginBottom: 16,
}}
>
{error}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text-dim)', textAlign: 'center', margin: 0 }}>
Falah OS CE v1.3 · Community Edition
</p>
</form>
</div>
</div>
)
}
+10
View File
@@ -25,6 +25,16 @@ export interface FalahApp {
category: string
ramzVerified: boolean
installed: boolean
// Gitea-sourced fields (present when loaded from iStore service)
featured?: boolean
version?: string
port?: number | null
image?: string | null
author?: string
license?: string
minCeVersion?: string
repoUrl?: string
updatedAt?: string
}
export interface WallpaperOption {