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:
@@ -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=
|
||||||
@@ -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
@@ -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 ""
|
||||||
Generated
-39
@@ -897,9 +897,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -914,9 +911,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -931,9 +925,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -948,9 +939,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -965,9 +953,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -982,9 +967,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -999,9 +981,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1016,9 +995,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1033,9 +1009,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1050,9 +1023,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1067,9 +1037,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1084,9 +1051,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1101,9 +1065,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+123
-5
@@ -1,22 +1,140 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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(cors());
|
||||||
app.use(express.json());
|
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) => {
|
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) => {
|
app.get('*', (req, res, next) => {
|
||||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
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}` }));
|
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'));
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,16 +1,44 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useApp } from './store'
|
import { useApp } from './store'
|
||||||
import Onboarding from './screens/Onboarding'
|
import Onboarding from './screens/Onboarding'
|
||||||
import Login from './screens/Login'
|
import Login from './screens/Login'
|
||||||
import Desktop from './screens/Desktop'
|
import Desktop from './screens/Desktop'
|
||||||
|
import { handleOAuthCallback } from './hooks/useAuth'
|
||||||
|
import type { AuthUser } from './types'
|
||||||
|
|
||||||
export default function App() {
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') closeOverlay()
|
if (e.key === 'Escape') closeOverlay()
|
||||||
// '/' is handled inside Desktop
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
@@ -26,6 +54,15 @@ export default function App() {
|
|||||||
transition: 'opacity 0.4s, transform 0.4s',
|
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 (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||||
<div className={wallpaperClass} />
|
<div className={wallpaperClass} />
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const API = {
|
|||||||
qurban: `http://${host}:3016`,
|
qurban: `http://${host}:3016`,
|
||||||
sadaqah: `http://${host}:3014`,
|
sadaqah: `http://${host}:3014`,
|
||||||
infaq: `http://${host}:3015`,
|
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> {
|
export async function apiFetch<T>(url: string, token?: string | null, opts?: RequestInit): Promise<T> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -185,3 +185,9 @@ input { font-family: inherit; border: none; outline: none; }
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 99px; }
|
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 99px; }
|
||||||
* { scrollbar-width: thin; scrollbar-color: var(--glass-border) transparent; }
|
* { 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
@@ -1,11 +1,16 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useApp } from '../store'
|
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'
|
import type { FalahApp } from '../types'
|
||||||
|
|
||||||
const FEATURED_APP_DATA: FalahApp[] = STORE_APPS.filter(a =>
|
type InstallStatus = 'not_installed' | 'pending' | 'pulling' | 'starting' | 'running' | 'stopped' | 'error'
|
||||||
FEATURED_APPS.includes(a.id)
|
|
||||||
)
|
interface AppRecord {
|
||||||
|
id: string
|
||||||
|
status: InstallStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
const FEATURED_GRADIENTS: Record<string, string> = {
|
const FEATURED_GRADIENTS: Record<string, string> = {
|
||||||
'zakat-vault': 'linear-gradient(135deg, #DC2626, #7F1D1D)',
|
'zakat-vault': 'linear-gradient(135deg, #DC2626, #7F1D1D)',
|
||||||
@@ -17,13 +22,76 @@ export default function IStore() {
|
|||||||
const { overlay, closeOverlay } = useApp()
|
const { overlay, closeOverlay } = useApp()
|
||||||
const isOpen = overlay === 'istore'
|
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 [activeCategory, setActiveCategory] = useState('All')
|
||||||
const [installed, setInstalled] = useState<Set<string>>(new Set())
|
const [appStatuses, setAppStatuses] = useState<Record<string, AppRecord>>({})
|
||||||
const [progress, setProgress] = useState<Record<string, number>>({})
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
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 matchesCat = activeCategory === 'All' || app.category === activeCategory
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchQuery === '' ||
|
searchQuery === '' ||
|
||||||
@@ -32,33 +100,32 @@ export default function IStore() {
|
|||||||
return matchesCat && matchesSearch
|
return matchesCat && matchesSearch
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleInstall(appId: string) {
|
async function handleInstall(appId: string) {
|
||||||
if (installed.has(appId) || progress[appId] !== undefined) return
|
const current = appStatuses[appId]?.status
|
||||||
setProgress(prev => ({ ...prev, [appId]: 0 }))
|
if (current === 'running' || current === 'pulling' || current === 'starting' || current === 'pending') return
|
||||||
let pct = 0
|
|
||||||
const interval = setInterval(() => {
|
setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'pending' } }))
|
||||||
pct += 100 / 15
|
|
||||||
if (pct >= 100) {
|
try {
|
||||||
clearInterval(interval)
|
const r = await fetch(`${API.appManager}/api/install/${appId}`, { method: 'POST' })
|
||||||
delete timerRefs.current[appId]
|
const data: AppRecord = await r.json()
|
||||||
setProgress(prev => {
|
setAppStatuses(prev => ({ ...prev, [appId]: data }))
|
||||||
const next = { ...prev }
|
if (data.status !== 'running') startPolling(appId)
|
||||||
delete next[appId]
|
} catch {
|
||||||
return next
|
setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'error', error: 'App Manager unreachable' } }))
|
||||||
})
|
}
|
||||||
setInstalled(prev => new Set(prev).add(appId))
|
|
||||||
} else {
|
|
||||||
setProgress(prev => ({ ...prev, [appId]: Math.min(Math.round(pct), 99) }))
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
timerRefs.current[appId] = interval
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
async function handleUninstall(appId: string) {
|
||||||
return () => {
|
try {
|
||||||
Object.values(timerRefs.current).forEach(clearInterval)
|
await fetch(`${API.appManager}/api/uninstall/${appId}`, { method: 'DELETE' })
|
||||||
}
|
setAppStatuses(prev => {
|
||||||
}, [])
|
const next = { ...prev }
|
||||||
|
delete next[appId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -143,6 +210,21 @@ export default function IStore() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Category chips */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -154,7 +236,7 @@ export default function IStore() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map(cat => (
|
{categories.map(cat => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
className={`cat-chip${activeCategory === cat ? ' active' : ''}`}
|
className={`cat-chip${activeCategory === cat ? ' active' : ''}`}
|
||||||
@@ -213,7 +295,7 @@ export default function IStore() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured section — only when showing All and no search filter */}
|
{/* Featured section — only when showing All and no search filter */}
|
||||||
{activeCategory === 'All' && searchQuery === '' && (
|
{activeCategory === 'All' && searchQuery === '' && featuredApps.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>Featured</h3>
|
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>Featured</h3>
|
||||||
@@ -227,8 +309,8 @@ export default function IStore() {
|
|||||||
gap: '14px',
|
gap: '14px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{FEATURED_APP_DATA.map(app => (
|
{featuredApps.map(app => (
|
||||||
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? ''} />
|
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? 'linear-gradient(135deg, #10B981, #059669)'} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,8 +323,13 @@ export default function IStore() {
|
|||||||
{activeCategory === 'All' ? 'All Apps' : activeCategory}
|
{activeCategory === 'All' ? 'All Apps' : activeCategory}
|
||||||
</h3>
|
</h3>
|
||||||
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
|
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
{filteredApps.length} apps
|
{filteredApps.length} app{filteredApps.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{liveApps.length > 0 && (
|
||||||
|
<span style={{ color: 'var(--brand)', fontSize: '11px', marginLeft: 'auto' }}>
|
||||||
|
● live from git.falahos.my
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -256,9 +343,10 @@ export default function IStore() {
|
|||||||
<AppRow
|
<AppRow
|
||||||
key={app.id}
|
key={app.id}
|
||||||
app={app}
|
app={app}
|
||||||
isInstalled={installed.has(app.id)}
|
status={appStatuses[app.id]?.status ?? 'not_installed'}
|
||||||
installProgress={progress[app.id]}
|
installError={appStatuses[app.id]?.error}
|
||||||
onInstall={() => handleInstall(app.id)}
|
onInstall={() => handleInstall(app.id)}
|
||||||
|
onUninstall={() => handleUninstall(app.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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({
|
function AppRow({
|
||||||
app,
|
app,
|
||||||
isInstalled,
|
status,
|
||||||
installProgress,
|
installError,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
onUninstall,
|
||||||
}: {
|
}: {
|
||||||
app: FalahApp
|
app: FalahApp
|
||||||
isInstalled: boolean
|
status: InstallStatus
|
||||||
installProgress: number | undefined
|
installError?: string
|
||||||
onInstall: () => void
|
onInstall: () => void
|
||||||
|
onUninstall: () => void
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const inProgress = IN_PROGRESS.includes(status)
|
||||||
|
const isRunning = status === 'running'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -396,28 +500,16 @@ function AppRow({
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--text)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{app.name}
|
{app.name}
|
||||||
|
{app.version && (
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-dim)', fontWeight: 400, marginLeft: '5px' }}>
|
||||||
|
v{app.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ fontSize: '11px', color: status === 'error' ? '#F87171' : 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
style={{
|
{status === 'error' ? (installError || 'Install failed') : app.tagline}
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{app.tagline}
|
|
||||||
</div>
|
</div>
|
||||||
{app.ramzVerified && (
|
{app.ramzVerified && (
|
||||||
<div className="ramz-badge">
|
<div className="ramz-badge">
|
||||||
@@ -427,50 +519,34 @@ function AppRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Install button */}
|
{/* Action */}
|
||||||
<div style={{ flexShrink: 0 }}>
|
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
|
||||||
{installProgress !== undefined ? (
|
{inProgress ? (
|
||||||
<div
|
<div style={{ width: '64px', display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
|
||||||
style={{
|
{/* Indeterminate progress bar */}
|
||||||
width: '64px',
|
<div style={{ width: '100%', height: '3px', borderRadius: '99px', background: 'rgba(255,255,255,0.1)', overflow: 'hidden' }}>
|
||||||
display: 'flex',
|
<div style={{ height: '100%', width: '40%', background: 'var(--brand)', borderRadius: '99px', animation: 'slide 1.2s ease-in-out infinite' }} />
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{installProgress}%</span>
|
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{STATUS_LABEL[status]}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
className={`btn-install${isInstalled ? ' btn-installed' : ''}`}
|
<button
|
||||||
onClick={e => {
|
className={`btn-install${isRunning ? ' btn-installed' : ''}`}
|
||||||
e.stopPropagation()
|
onClick={e => { e.stopPropagation(); if (!isRunning) onInstall() }}
|
||||||
onInstall()
|
disabled={isRunning}
|
||||||
}}
|
>
|
||||||
disabled={isInstalled}
|
{STATUS_LABEL[status]}
|
||||||
>
|
</button>
|
||||||
{isInstalled ? 'Installed' : 'Install'}
|
{isRunning && (
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+69
-99
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useApp } from '../store'
|
import { fetchAuthConfig, initiateLogin } from '../hooks/useAuth'
|
||||||
|
|
||||||
function Logo({ size }: { size: number }) {
|
function Logo({ size }: { size: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -25,37 +25,21 @@ function Logo({ size }: { size: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { navigate } = useApp()
|
const [loading, setLoading] = useState(false)
|
||||||
const [email, setEmail] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [emailFocused, setEmailFocused] = useState(false)
|
|
||||||
const [passwordFocused, setPasswordFocused] = useState(false)
|
|
||||||
|
|
||||||
const inputStyle = (focused: boolean): React.CSSProperties => ({
|
const handleSignIn = useCallback(async () => {
|
||||||
width: '100%',
|
setLoading(true)
|
||||||
padding: '12px 16px',
|
setError('')
|
||||||
background: focused ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.06)',
|
try {
|
||||||
border: `1px solid ${focused ? 'var(--brand)' : 'var(--glass-border)'}`,
|
const config = await fetchAuthConfig()
|
||||||
borderRadius: 8,
|
await initiateLogin(config)
|
||||||
color: 'var(--text)',
|
// page will redirect — no further state updates needed
|
||||||
fontSize: 14,
|
} catch (err) {
|
||||||
outline: 'none',
|
setError(err instanceof Error ? err.message : 'Could not reach auth server')
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
setLoading(false)
|
||||||
})
|
}
|
||||||
|
}, [])
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,8 +52,7 @@ export default function Login() {
|
|||||||
padding: 24,
|
padding: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<div
|
||||||
onSubmit={handleSignIn}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
@@ -83,7 +66,6 @@ export default function Login() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Arabic */}
|
{/* Arabic */}
|
||||||
@@ -100,83 +82,71 @@ export default function Login() {
|
|||||||
بِسْمِ اللَّهِ
|
بِسْمِ اللَّهِ
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<Logo size={72} />
|
<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
|
Falah OS
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Subtitle */}
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 36px', textAlign: 'center' }}>
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
margin: '0 0 32px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sovereign Digital Economy
|
Sovereign Digital Economy
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Email field */}
|
{/* UmmahID sign-in block */}
|
||||||
<div style={{ width: '100%', marginBottom: 16 }}>
|
<div
|
||||||
<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
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
width: '100%',
|
||||||
color: 'var(--text-dim)',
|
background: 'rgba(16,185,129,0.06)',
|
||||||
textAlign: 'center',
|
border: '1px solid rgba(16,185,129,0.18)',
|
||||||
margin: 0,
|
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
|
Falah OS CE v1.3 · Community Edition
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export interface FalahApp {
|
|||||||
category: string
|
category: string
|
||||||
ramzVerified: boolean
|
ramzVerified: boolean
|
||||||
installed: 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 {
|
export interface WallpaperOption {
|
||||||
|
|||||||
Reference in New Issue
Block a user