initial upload

This commit is contained in:
Kai Waggeling 2025-05-17 16:40:38 +02:00
parent 4c2141d89d
commit 2a9bd4e81b
33 changed files with 1238 additions and 0 deletions

4
assets/css/tabler-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/img/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/img/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9
config.yaml Normal file
View file

@ -0,0 +1,9 @@
application:
name: Identity Manager
logo:
dark: /img/logo-dark.png
light: /img/logo-light.png
port: 3000
mfa:
otp:
issuer: example.com

15
lib/config.mjs Normal file
View file

@ -0,0 +1,15 @@
import {
parse as parseYaml
} from 'yaml';
import {
readFileSync
} from "node:fs";
export async function getConfig() {
return parseYaml(
readFileSync('config.yaml', 'utf-8')
);
}

107
lib/mysql.mjs Normal file
View file

@ -0,0 +1,107 @@
import mysql from "mysql2/promise";
import {
generateOTPSecret
} from "./otp.mjs";
// Create the connection to database
const connection = await mysql.createConnection({
host: '10.0.0.31',
port: 33063,
database: 'glauth',
user: 'glauth',
password: 'b848dc7aa44b66bbcc1e5991a6ae45ce'
});
export async function login(username, password) {
try {
const [rows] = await connection.execute('SELECT id, uidnumber, name, otpsecret FROM `users` WHERE `name` = ? AND `passsha256` = ? AND `disabled` = 0', [username, password]);
if (rows.length == 0) {
throw new Error(`user ${username} not found.`);
}
if (rows.length > 1) {
throw new Error("more than 1 user found.");
}
console.log(`user ${rows[0].name} logged in.`);
return rows[0];
} catch (error) {
console.log(`login failed: ${error.message}`);
return null;
}
}
export async function getUser(userid) {
try {
const [rows] = await connection.execute('SELECT uidnumber, name, givenname, sn, mail, custattr FROM `users` WHERE `id` = ?', [userid]);
if (rows.length == 0) {
throw new Error("no user found.");
}
return rows[0];
} catch (error) {
return null;
}
}
export async function getUserMFA(userid) {
try {
const [rows] = await connection.execute('SELECT otpsecret, yubikey FROM `users` WHERE `id` = ?', [userid]);
if (rows.length == 0) {
throw new Error("no user found.");
}
return rows[0];
} catch (error) {
return null;
}
}
export async function setOTPSecret(userid, otpsecret) {
try {
await connection.execute('UPDATE `users` SET `otpsecret` = ? WHERE `id` = ?', [otpsecret, userid]);
return true;
} catch (error) {
return false;
}
}
export async function getUsers() {
let [mysqlUsers] = await connection.execute('SELECT id, name, uidnumber, mail, disabled FROM `users`', []);
return mysqlUsers;
}
export async function getGroups() {
let [mysqlGroups] = await connection.execute('SELECT * FROM `ldapgroups`', []);
return mysqlGroups;
}
export async function getUserGroups(userid) {
try {
let [mysqlUsers] = await connection.execute('SELECT primarygroup, othergroups FROM `users` WHERE `id` = ?', [userid]);
let [mysqlGroups] = await connection.execute('SELECT * FROM `ldapgroups`', []);
mysqlGroups = mysqlGroups.map((mysqlGroup) => {
return {
id: mysqlGroup.id,
name: mysqlGroup.name
}
});
let result = [];
if (mysqlUsers[0].primarygroup != '') {
}
return true;
} catch (error) {
return false;
}
}

59
lib/otp.mjs Normal file
View file

@ -0,0 +1,59 @@
import qrcode from "qrcode";
import {
Secret,
TOTP
} from "otpauth";
import {
getConfig
} from "./config.mjs";
import {
setOTPSecret
} from "./mysql.mjs";
let appConfig = await getConfig();
export function generateOTPSecret() {
return new Promise((resolve, reject) => {
let otpSecret = new Secret({ length: 20 });
resolve(otpSecret.base32);
})
}
export function generateOTPQRCode(account, otpsecret) {
return new Promise(async (resolve, reject) => {
let totp = new TOTP({
issuer: appConfig.mfa.otp.issuer,
label: account,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: otpsecret
});
qrcode.toDataURL(totp.toString(), (error, url) => {
resolve(url)
})
})
}
export function validateOTPCode(account, otpsecret, token) {
return new Promise((resolve, reject) => {
let totp = new TOTP({
issuer: appConfig.mfa.otp.issuer,
label: account,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: otpsecret
});
resolve(totp.validate({ token, window: 2 }));
});
}
export async function saveOTPSecret(userid, otpsecret) {
setOTPSecret(userid, otpsecret)
}

69
master.mjs Normal file
View file

@ -0,0 +1,69 @@
// Load WebServer
import {
default as express
} from "express";
// Load Templating Engine
import {
default as nunjucks
} from "nunjucks";
import {
router
} from "express-file-routing"
import sessions from "express-session";
// Initialize WebServer
const expressApp = express();
const expressPort = 3000;
// Configure Templating Engine
nunjucks.configure('./', {
autoescape: false,
express: expressApp,
noCache: true
});
// Enable Express Sessions
expressApp.use(sessions({
secret: 'keyboard cat',
resave: false,
rolling: true,
saveUninitialized: false,
cookie: {
secure: false,
maxAge: 1800 * 1000,
httpOnly: true
}
}))
// Enable Cross Origins
expressApp.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
next();
});
// Mount Middlewares to WebServer
expressApp.use(express.urlencoded());
expressApp.use(express.json());
expressApp.use(express.static('./assets/'))
expressApp.use((request, response, next) => {
request.isLoginCompleted = () => {
if (typeof request.session.login == 'object' && request.session.login.completed) {
return true;
} else {
return false;
}
}
next();
})
expressApp.use("/", await router())
// Server starten
expressApp.listen(expressPort, () => {
console.log(`Server is listening on port ${expressPort}`);
});

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "formassist",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.1",
"express-file-routing": "^3.0.3",
"express-session": "^1.18.1",
"ldapjs-client": "^0.1.2",
"mysql2": "^3.12.0",
"nunjucks": "^3.2.4",
"otpauth": "^9.3.6",
"qrcode": "^1.5.4",
"yaml": "^2.6.1"
}
}

36
routes/admin/[page].mjs Normal file
View file

@ -0,0 +1,36 @@
import {
getUsers,
getGroups
} from "../../lib/mysql.mjs";
export const get = async function (request, response) {
// if (!request.isLoginCompleted()) {
// response.redirect('/login');
// return;
// }
switch (request.params.page) {
case 'users':
response.render(`ui/admin.njk`, {
page: 'users',
users: await getUsers()
});
break;
case 'groups':
response.render(`ui/admin.njk`, {
page: 'groups',
groups: await getGroups()
});
break;
default:
response.redirect('/admin/users');
break;
}
}
export const post = async function (request, response) {
console.log(request.body);
response.redirect("/login");
}

4
routes/admin/index.mjs Normal file
View file

@ -0,0 +1,4 @@
export const get = async function (request, response) {
response.redirect('/admin/users');
}

78
routes/login.mjs Normal file
View file

@ -0,0 +1,78 @@
import crypto from "crypto";
import {
login,
getUser,
getUserMFA
} from "../lib/mysql.mjs";
import {
validateOTPCode
} from "../lib/otp.mjs";
export const get = async function(request, response) {
if (typeof request.session.userid != 'string') {
response.render(`ui/login.njk`, {
step: 'login'
});
return;
}
if (request.session.otpVerified != true) {
response.render(`ui/login.njk`, {
step: 'otp'
});
return;
}
}
export const post = async function(request, response) {
if (typeof request.body.username == 'string' && typeof request.body.password == 'string') {
let username = request.body.username;
let password = crypto.createHash('sha256').update(request.body.password).digest('hex')
let loginResult = await login(username, password)
if (loginResult == null) {
response.render(`ui/login.njk`, {
step: 'login',
error: 'login failed'
});
return;
}
request.session.userid = loginResult.id;
request.session.login = {
completed: false,
otpVerified: false
}
request.session.save();
if (loginResult.otpsecret != '' && loginResult.yubikey != '') {
response.render(`ui/login.njk`, {
step: 'otp'
});
return;
} else {
request.session.login.completed = true;
response.redirect('/profile')
}
} else if (typeof request.body.otpToken == 'string') {
let otpToken = request.body.otpToken;
let userData = await getUser(request.session.userid);
let mfaData = await getUserMFA(request.session.userid);
let validationResult = await validateOTPCode(userData.mail, mfaData.otpsecret, otpToken);
if (validationResult != null) {
request.session.login.completed = true;
response.redirect('/profile');
} else {
request.session.destroy();
response.render(`ui/login.njk`, {
step: 'login',
error: 'otp failed'
});
}
}
}

5
routes/logout.mjs Normal file
View file

@ -0,0 +1,5 @@
export const get = async function(request, response) {
request.session.destroy();
response.render(`ui/logout.njk`);
}

59
routes/profile/[page].mjs Normal file
View file

@ -0,0 +1,59 @@
import {
getUser,
getUserMFA
} from "../../lib/mysql.mjs";
import {
generateOTPQRCode
} from "../../lib/otp.mjs";
export const get = async function(request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
let userData = await getUser(request.session.userid);
let mfaData = await getUserMFA(request.session.userid);
switch (request.params.page) {
case 'personal':
response.render(`ui/profile.njk`, {
page: 'profile/personal',
data: {
firstName: userData.givenname,
lastName: userData.sn,
mail: userData.mail,
}
});
break;
case 'security':
response.render(`ui/profile.njk`, {
page: 'profile/security',
otp: {
active: mfaData.otpsecret != '' ? true : false,
qrcode: await generateOTPQRCode(userData.mail, mfaData.otpsecret)
}
});
break;
case 'createOTPSecret':
response.render(`ui/profile.njk`, {
page: 'profile/createOTPSecret',
otp: {
active: mfaData.otpsecret != '' ? true : false,
qrcode: await generateOTPQRCode(userData.mail, mfaData.otpsecret)
}
});
break;
default:
response.redirect('/page/personal');
break;
}
}
export const post = async function(request, response) {
console.log(request.body);
response.redirect("/login");
}

9
routes/profile/index.mjs Normal file
View file

@ -0,0 +1,9 @@
export const get = async function (request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
response.redirect('/profile/personal');
}

View file

@ -0,0 +1,62 @@
import {
generateOTPQRCode,
generateOTPSecret,
validateOTPCode,
saveOTPSecret
} from "../../../lib/otp.mjs";
import {
getUser,
getUserMFA
} from "../../../lib/mysql.mjs";
export const get = async function (request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
if (typeof request.session.otpConfig != 'object') {
request.session.otpConfig = {
completed: false,
otpSecret: await generateOTPSecret()
}
}
let userData = await getUser(request.session.userid);
let otpsecret = request.session.otpConfig.otpSecret;
response.render(`ui/profile.njk`, {
page: 'otp/create',
otp: {
active: request.session.otpConfig.completed != '' ? true : false,
qrcode: await generateOTPQRCode(userData.name, otpsecret),
otpsecret: request.session.otpConfig.otpSecret
}
});
}
export const post = async function (request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
let userData = await getUser(request.session.userid);
if (validateOTPCode(userData.name, request.body.otpsecret, request.body.otpcode)) {
saveOTPSecret(request.session.userid, request.body.otpsecret)
response.render(`ui/messages/success.njk`, {
message: {
title: 'OTP Secret created!',
text: 'Your new OTP-Secret was successfull generated',
link: '/profile/security'
}
});
} else {
response.redirect('/profile/otp/create')
}
}

View file

@ -0,0 +1,62 @@
import {
generateOTPQRCode,
generateOTPSecret,
validateOTPCode,
saveOTPSecret
} from "../../../lib/otp.mjs";
import {
getUser,
getUserMFA
} from "../../../lib/mysql.mjs";
export const get = async function (request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
if (typeof request.session.otpConfig != 'object') {
request.session.otpConfig = {
completed: false,
otpSecret: await generateOTPSecret()
}
}
let userData = await getUser(request.session.userid);
let otpsecret = request.session.otpConfig.otpSecret;
response.render(`ui/profile.njk`, {
page: 'otp/create',
otp: {
active: request.session.otpConfig.completed != '' ? true : false,
qrcode: await generateOTPQRCode(userData.name, otpsecret),
otpsecret: request.session.otpConfig.otpSecret
}
});
}
export const post = async function (request, response) {
if (!request.isLoginCompleted()) {
response.redirect('/login');
return;
}
let userData = await getUser(request.session.userid);
if (validateOTPCode(userData.name, request.body.otpsecret, request.body.otpcode)) {
saveOTPSecret(request.session.userid, request.body.otpsecret)
response.render(`ui/messages/success.njk`, {
message: {
title: 'OTP Secret created!',
text: 'Your new OTP-Secret was successfull generated',
link: '/profile/security'
}
});
} else {
response.redirect('/profile/otp/create')
}
}

127
ui/admin.njk Normal file
View file

@ -0,0 +1,127 @@
{% extends "./master.njk" %}
{% block content %}
<div id="app" class="w-full">
{% include "./components/navbar.njk" %}
<div class="container relative mx-auto py-12 flex flex-col justify-center items-center gap-8">
<div class="flex flex-row gap-8 w-full text-gray-700">
<!-- Setting Menu -->
<div class="flex flex-col w-2/6">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 shadow-md rounded-lg gap-6">
<div class="flex w-full">
<h2 class="text-xl font-bold dark:text-gray-200">Settings</h2>
</div>
<div class="flex flex-col gap-3">
{% if page == 'users' %}
<a href="/admin/users" class="py-3 px-5 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-blue-500 bg-gray-100 dark:bg-gray-900">
{% else %}
<a href="/admin/users" class="py-3 px-5 text-sm text-left rounded-lg text-gray-700 dark:text-gray-300 hover:text-gray-100 hover:bg-blue-500">
{% endif %}
Users
</a>
{% if page == 'groups' %}
<a href="/admin/groups" class="py-3 px-5 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-blue-500 bg-gray-100 dark:bg-gray-900">
{% else %}
<a href="/admin/groups" class="py-3 px-5 text-sm text-left rounded-lg text-gray-700 dark:text-gray-300 hover:text-gray-100 hover:bg-blue-500">
{% endif %}
Groups
</a>
</div>
</div>
</div>
<div class="flex flex-col flex-auto gap-8">
{% if page == 'users' %}
<!-- Table: Users -->
<div class="relative overflow-x-auto shadow-md rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<table class="w-full text-sm text-left">
<caption class="py-5 px-8 text-xl font-semibold text-left">
Users
<p class="mt-1 text-sm font-normal text-gray-500 dark:text-gray-400">
Manage the users in the user directory
</p>
</caption>
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
Mail
</th>
<th scope="col" class="px-6 py-3 text-center">
Enabled
</th>
<th scope="col" class="px-6 py-3">
</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="border-b dark:border-gray-700 hover:bg-gray-100 hover:dark:bg-gray-700">
<th class="px-6 py-4 select-all font-medium whitespace-nowrap">
{{user.name}}
</th>
<td class="px-6 py-4 select-all">
{{user.mail}}
</td>
{% if user.disabled == 1 %}
<td class="px-6 py-4 text-2xl text-center text-red-400">
<i class="ti ti-square-rounded-x-filled"></i>
</td>
{% else %}
<td class="px-6 py-4 text-2xl text-center text-green-500">
<i class="ti ti-square-rounded-check-filled"></i>
</td>
{% endif %}
<td class="px-6 py-4 text-right">
<a href="/admin/user/{{user.id}}" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if page == 'groups' %}
<!-- Table: Groups -->
<div class="relative overflow-x-auto shadow-md rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<caption class="py-5 px-8 text-xl font-semibold text-left rtl:text-right text-gray-900 bg-white dark:text-white dark:bg-gray-800">
Groups
<p class="mt-1 text-sm font-normal text-gray-500 dark:text-gray-400">
Manage the groups in the user directory
</p>
</caption>
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
</th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<th class="px-6 py-4 select-all font-medium text-gray-900 whitespace-nowrap dark:text-white">
{{group.name}}
</th>
<td class="px-6 py-4 text-right">
<a href="/admin/group/{{group.id}}" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- / -->
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.js"></script>
{% endblock %}

20
ui/components/meta.njk Normal file
View file

@ -0,0 +1,20 @@
<link rel="icon" type="image/x-icon" href="/img/AppLogo.svg">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/css/tabler-icons.min.css">
<!-- <script src="/js/vue.global.prod.js"></script> -->
<script src="https://unpkg.com/vue@latest"></script>
<style>
body,
html {
/* width: 100svw; */
min-height: 100svh;
user-select: none;
background: var(--bs-body-bg);
}
</style>

27
ui/components/navbar.njk Normal file
View file

@ -0,0 +1,27 @@
<header class="sticky top-0 z-50 w-full flex flex-col bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-600">
<nav class="container flex justify-between items-center py-4 mx-auto relative ">
<!-- Application Logo -->
<a href="https://flowbite.com/" class="flex items-center space-x-3 rtl:space-x-reverse z-10">
<img src="https://flowbite.com/docs/images/logo.svg" class="h-6" alt="Flowbite Logo" />
<span class="self-center text-xl font-semibold whitespace-nowrap">Identity Manager</span>
</a>
<!-- Menu Items -->
<div class="flex items-center gap-12">
<a href="/profile" class="rounded text-gray-900 hover:text-blue-700 dark:text-white dark:hover:text-blue-500">
Profile
</a>
<a href="/admin" class="rounded text-gray-900 hover:text-blue-700 dark:text-white dark:hover:text-blue-500">
Administration
</a>
</div>
<!-- User Menu -->
<div class="flex flex-row gap-2 z-10">
<a href="/logout" class="py-2 px-6 rounded text-sm text-white bg-primary-500 hover:bg-primary-600">Sign Out</a>
</div>
</nav>
</header>

37
ui/components/widgets.njk Normal file
View file

@ -0,0 +1,37 @@
{% macro jumbotron(id='', title='', text='') %}
<section class="w-full">
<div class="py-4 px-4 mx-auto max-w-screen-xl text-center">
<h1 class="my-4 font-extrabold tracking-tight leading-none text-gray-900 text-4xl md:text-5xl lg:text-6xl">
{{ title }}
</h1>
<p class="my-4 text-lg font-normal text-gray-500 lg:text-xl sm:px-16 lg:px-48">
{{ text }}
</p>
{{ caller() }}
</div>
</section>
{% endmacro %}
{% macro container(id='', width='w-full', flow='row', vAlign='center', hAlign='center') %}
<div class="container mx-auto {{width}} pt-24 pb-32 flex flex-{{flow}} justify-{{hAlign}} items-{{vAlign}}">
{{ caller() }}
</div>
{% endmacro %}
{% macro grid(id='', title='', columns=3) %}
<div class="my-4 grid grid-cols-1 md:grid-cols-{{2 if columns > 2 else columns}} lg:grid-cols-{{columns}} gap-6">
{{ caller() }}
</div>
{% endmacro %}
{% macro link(id='', link='#', title='New Link', description='Description') %}
<a href="{{ link }}" target="_blank" class="p-8 bg-gray-50 rounded-md border border-gray-200 hover:border-purple-400">
<h4 class="font-medium text-gray-700 text-lg mb-4">
{{ title }}
</h4>
<p class="font-normal text-gray-500 text-md">
{{ description }}
</p>
</a>
{% endmacro %}

81
ui/login.njk Normal file
View file

@ -0,0 +1,81 @@
{% extends "./master.njk" %}
{% block content %}
<div id="app" class="w-full flex align-center" style="min-height: 100svh;">
<div class="container relative mx-auto py-12 flex flex-col justify-center items-center gap-8">
{% if error == "login failed" %}
<!-- Login Error -->
<div id="alert-login-failed" class="flex items-center w-full max-w-md px-4 py-3 text-red-800 border-t-4 border-red-300 bg-white dark:text-red-400 dark:bg-gray-800 dark:border-red-800 shadow rounded-lg" role="alert">
<i class="ti ti-alert-hexagon-filled text-lg"></i>
<div class="ms-3 text-sm font-medium">
Login failed. Username or password is incorrect.
</div>
</div>
{% endif %}
{% if error == "otp failed" %}
<!-- Login Error -->
<div id="alert-login-failed" class="flex items-center w-full max-w-md px-4 py-3 text-red-800 border-t-4 border-red-300 bg-white dark:text-red-400 dark:bg-gray-800 dark:border-red-800 shadow rounded-lg" role="alert">
<i class="ti ti-alert-hexagon-filled text-lg"></i>
<div class="ms-3 text-sm font-medium">
Login failed. OTP token is incorrect.
</div>
</div>
{% endif %}
{% if step == "login" %}
<!-- Login Dialoge -->
<div class="w-full max-w-md bg-white rounded-lg shadow dark:border md:mt-0 xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div class="px-4 flex flex-col items-center gap-4">
<img class="min-h-4 max-w-full max-h-64" src="/img/logo-dark.png" alt="logo">
<h1 class="text-3xl text-gray-900 dark:text-white">
Identity Manager
</h1>
</div>
<form class="space-y-4 md:space-y-6" action="/login" method="post" enctype="application/x-www-form-urlencoded">
<div>
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Your email
</label>
<input type="text" name="username" class="w-full py-2 px-4 text-sm bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:border-primary-600 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" placeholder="john-doe" required="">
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Password
</label>
<input type="password" name="password" class="w-full py-2 px-4 text-sm bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:border-primary-600 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" placeholder="••••••••" required="">
</div>
<div class="flex items-center justify-end">
<a href="#" class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-500">Forgot password?</a>
</div>
<button type="submit" class="w-full px-5 py-3 text-sm text-center text-white bg-primary-600 hover:bg-primary-700 rounded-lg">Sign in</button>
</form>
</div>
</div>
{% endif %}
{% if step == "otp" %}
<!-- TOTP Dialoge -->
<div class="w-full max-w-md bg-white rounded-lg shadow dark:border md:mt-0 xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div class="px-4 flex flex-col items-center gap-4">
<img class="min-h-4 max-w-full max-h-64" src="/img/logo-dark.png" alt="logo">
<h1 class="text-3xl text-gray-900 dark:text-white">
Identity Manager
</h1>
</div>
<form class="space-y-4 md:space-y-6" action="/login" method="post" enctype="application/x-www-form-urlencoded">
<div>
<label for="otpToken" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
OTP Token
</label>
<input type="text" name="otpToken" class="w-full py-2 px-4 text-sm bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:border-primary-600 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" placeholder="12345" required="">
</div>
<button type="submit" class="w-full px-5 py-3 text-sm text-center text-white bg-primary-600 hover:bg-primary-700 rounded-lg">Continue</button>
</form>
</div>
</div>
<!-- / -->
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.js"></script>
{% endblock %}

21
ui/logout.njk Normal file
View file

@ -0,0 +1,21 @@
{% extends "./master.njk" %}
{% block content %}
<div id="app" class="w-full flex align-center" style="min-height: 100svh;">
<div class="container relative mx-auto py-12 flex flex-col justify-center items-center gap-8">
<!-- Login Dialoge -->
<div class="flex flex-col w-full max-w-md p-6 gap-4 bg-white dark:bg-gray-800 dark:border dark:border-gray-700 rounded-lg shadow">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<h3 class="text-2xl text-gray-900 dark:text-white">
You have been logged out
</h3>
</div>
</div>
<a href="/login" class="w-full px-5 py-3 text-sm text-center text-white bg-primary-600 hover:bg-primary-700 rounded-lg">Sign in</a>
</div>
<!-- / -->
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.js"></script>
{% endblock %}

56
ui/master.njk Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de">
<head>
<title>Shell Script Hub</title>
{% include "./components/meta.njk" %}
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');
* {
box-sizing: border-box;
}
</style>
<script>
// dark / light mode
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
"50": "#eff6ff",
"100": "#dbeafe",
"200": "#bfdbfe",
"300": "#93c5fd",
"400": "#60a5fa",
"500": "#3b82f6",
"600": "#2563eb",
"700": "#1d4ed8",
"800": "#1e40af",
"900": "#1e3a8a",
"950": "#172554"
}
}
},
fontFamily: {
'sans': [
'Poppins',
'Noto Sans'
]
}
}
}
</script>
</head>
<body class="bg-gray-100 dark:bg-gray-700 dark:text-white">
{% block content %}{% endblock %}
</body>
</html>

17
ui/messages/404.njk Normal file
View file

@ -0,0 +1,17 @@
{% extends "./master.njk" %}
{% block content %}
<div class="container">
<div class="d-flex align-items-center justify-content-center py-5">
<div class="text-center">
<h1 class="display-1 fw-bold">{{ Error.Code }}</h1>
<p class="fs-3"> <span class="text-danger">Opps!</span> {{ Error.Title }}</p>
<p class="lead">
{{ Error.Message }}
</p>
<a href="{{ Error.Link }}" class="btn btn-primary">Go Back</a>
</div>
</div>
</div>
{% endblock %}

20
ui/messages/success.njk Normal file
View file

@ -0,0 +1,20 @@
{% extends "../master.njk" %}
{% block content %}
<div id="app" class="w-full min-h-svh flex align-center justify-center">
<div class="m-auto max-w-lg relative p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow">
<h5 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">
{{ message.title }}
</h5>
<p class="mb-5 text-base text-gray-500 sm:text-lg dark:text-gray-400">
{{ message.text }}
</p>
<div class="flex items-center justify-end space-y-4 sm:flex sm:space-y-0 sm:space-x-4 rtl:space-x-reverse">
<a href="{{ message.link }}" class="py-2 px-6 rounded text-sm text-white bg-primary-500 hover:bg-primary-600">
Continue
</a>
</div>
</div>
</div>
{% endblock %}

229
ui/profile.njk Normal file
View file

@ -0,0 +1,229 @@
{% extends "./master.njk" %}
{% block content %}
<div id="app" class="w-full">
{% include "./components/navbar.njk" %}
<div class="container relative mx-auto py-12 flex flex-col justify-center items-center gap-8">
<div class="flex flex-row gap-8 w-full text-gray-700">
<!-- Setting Menu -->
<div class="flex flex-col w-2/6">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm gap-6">
<div class="flex w-full">
<h2 class="text-4xl font-bold dark:text-gray-200">Settings</h2>
</div>
<div class="flex flex-col gap-3">
{% if page == 'profile/personal' %}
<a href="/profile/personal" class="py-3 px-5 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-blue-500 bg-gray-100 dark:bg-gray-900">
{% else %}
<a href="/profile/personal" class="py-3 px-5 text-sm text-left rounded-lg text-gray-700 dark:text-gray-300 hover:text-gray-100 hover:bg-blue-500">
{% endif %}
Personal Data
</a>
{% if page == 'profile/security' %}
<a href="/profile/security" class="py-3 px-5 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-blue-500 bg-gray-100 dark:bg-gray-900">
{% else %}
<a href="/profile/security" class="py-3 px-5 text-sm text-left rounded-lg text-gray-700 dark:text-gray-300 hover:text-gray-100 hover:bg-blue-500">
{% endif %}
Security / Login
</a>
</div>
</div>
</div>
<div class="flex flex-col flex-auto gap-8">
<!-- Form: Personal Data -->
{% if page == 'profile/personal' %}
<div class="flex flex-col flex-auto">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm gap-6">
<div class="flex w-full">
<h2 class="text-4xl font-bold text-gray-900 dark:text-gray-200">
Personal Data
</h2>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-row align-center gap-4">
<div class="flex flex-col w-1/2 gap-3">
<label for="leihgeber.person" class="w-full text-sm font-medium text-gray-900 dark:text-white">
first name
</label>
<input type="text" name="firstName" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="John" value="{{ data.firstName }}">
</div>
<div class="flex flex-col w-1/2 gap-3">
<label for="leihgeber.person" class="w-full text-sm font-medium text-gray-900 dark:text-white">
last name
</label>
<input type="text" name="lastName" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="Doe" value="{{ data.lastName }}">
</div>
</div>
<div class="flex flex-col gap-3">
<label for="leihgeber.person" class="w-full text-sm font-medium text-gray-900 dark:text-white">
email
</label>
<input type="text" name="mail" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="john@doe" value="{{ data.mail }}">
</div>
</div>
<div class="flex w-full justify-end">
<button type="button" class="py-3 px-8 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-blue-500 dark:hover:text-blue-400 bg-gray-100 dark:bg-gray-600">
Save
</button>
</div>
</div>
</div>
{% endif %}
{% if page == 'profile/security' %}
<!-- Form: Change Password -->
<div class="flex flex-col flex-auto">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm gap-6">
<div class="flex w-full">
<h2 class="text-4xl font-bold text-gray-900 dark:text-gray-200">
Change Password
</h2>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col w-1/2 gap-3">
<label for="leihgeber.person" class="text-sm font-medium text-gray-900 dark:text-white">
old Password
</label>
<input type="password" name="old_password" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="••••••••" value="">
</div>
<div class="flex flex-row align-center gap-4">
<div class="flex flex-col w-1/2 gap-3">
<label for="leihgeber.person" class="w-full text-sm font-medium text-gray-900 dark:text-white">
new Password
</label>
<input type="password" name="new_password_1" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="••••••••" value="">
</div>
<div class="flex flex-col flex-auto gap-3">
<label for="leihgeber.person" class="w-full text-sm font-medium text-gray-900 dark:text-white">
repeat new Password
</label>
<input type="password" name="new_password_2" class="block w-full p-2.5 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 dark:focus:border-blue-500 text-sm text-gray-900 dark:text-white dark:placeholder-gray-400" placeholder="••••••••" value="">
</div>
</div>
</div>
</div>
</div>
<!-- Form: 2FA -->
<div class="flex flex-col flex-auto">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm gap-6">
<div class="flex w-full">
<h2 class="text-4xl font-bold text-gray-900 dark:text-gray-200">
Two-Factor Authentication
</h2>
</div>
{% if otp.active != true %}
<div class="flex w-full">
<div class="flex items-center w-full px-4 py-2 text-sm bg-yellow-100 dark:bg-yellow-600 dark:text-white rounded-lg gap-4" role="alert">
<i class="ti ti-alert-hexagon-filled text-lg"></i>
<div class="ms-3 text-sm font-medium">
You have not yet configured 2-factor authentication.
</div>
</div>
</div>
{% endif %}
<div class="flex gap-8">
{% if otp.active == true %}
<div class="flex flex-col w-48">
<img class="rounded-lg w-full" src="{{ otp.qrcode }}">
</div>
<div class="flex flex-col flex-auto gap-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex flex-col flex-auto gap-3">
<label class="w-full text-sm font-medium text-gray-900 dark:text-white">
your OTP-Secret:
</label>
<input type="text" name="otpsecret" class="w-full p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-blue-500 text-sm text-gray-900 dark:text-white" value="{{ otp.otpsecret }}" readonly>
</div>
<button type="button" class="py-3 px-8 mt-auto text-sm text-left rounded-lg text-red-700 dark:text-red-300 hover:text-gray-100 bg-gray-100 dark:bg-gray-600 hover:bg-red-500">
Delete
</button>
</div>
</div>
{% endif %}
</div>
<div class="flex flex-col w-full">
<div id="faq" data-accordion="collapse" class="flex flex-col gap-3" data-active-classes="text-primary-700 dark:text-primary-300" data-inactive-classes="text-gray-500 dark:text-gray-400">
<h2 id="faq-heading-1">
<a href="#" class="flex items-center justify-between w-full text-gray-500 dark:text-gray-400" data-accordion-target="#faq-body-1">
<span class="text-lg">How to use 2FA?</span>
<i data-accordion-icon class="ti ti-square-rounded-chevron-up text-2xl rotate-180"></i>
</a>
</h2>
<hr class="h-px bg-gray-200 border-0 dark:bg-gray-700">
<div id="faq-body-1" class="hidden" aria-labelledby="faq-heading-1">
<p class="mb-6 text-gray-800 dark:text-gray-200 text-sm">Each time you authenticate, you must add your current 2FA code at the end of your password.</p>
</div>
<h2 id="faq-heading-2">
<a href="#" class="flex items-center justify-between w-full text-gray-500 dark:text-gray-400" data-accordion-target="#faq-body-2">
<span class="text-lg">Which apps can I use with the 2FA code?</span>
<i data-accordion-icon class="ti ti-square-rounded-chevron-up text-2xl rotate-180"></i>
</a>
</h2>
<hr class="h-px bg-gray-200 border-0 dark:bg-gray-700">
<div id="faq-body-2" class="hidden" aria-labelledby="faq-heading-2">
<p class="mb-6 text-gray-800 dark:text-gray-200 text-sm">You can use any Google Authenticator compatible app. Keepass and Passbolt need the specific settings.</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if page == 'profile/otp/create' %}
<!-- Form: create OTP-Secret -->
<div class="flex flex-col flex-auto">
<div class="flex flex-col px-8 py-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm gap-6">
<div class="flex w-full">
<h2 class="text-4xl font-bold text-gray-900 dark:text-gray-200">
Two-Factor Authentication
</h2>
</div>
<div class="flex w-full">
<div class="flex flex-col w-full px-4 py-3 text-sm bg-blue-100 dark:bg-blue-600 dark:text-white rounded-lg gap-2" role="alert">
<div class="flex items-center gap-4">
<span class="font-medium">next steps:</span>
</div>
<ul class="px-3 list-disc list-inside">
<li>scan the QR-Code with your authenticator app or add your secret manually</li>
<li>enter the generated one-time password in the input field for confirmation</li>
<li>validate the code from your authenticator app</li>
</ul>
</div>
</div>
<form class="flex gap-8" action="/profile/otp/create" method="post" enctype="application/x-www-form-urlencoded">
<div class="flex flex-col w-48">
<img class="rounded-lg w-full" src="{{ otp.qrcode }}">
</div>
<div class="flex flex-col flex-auto gap-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex flex-col gap-3 lg:w-4/6">
<label class="w-full text-sm font-medium text-gray-900 dark:text-white">
your OTP-Secret:
</label>
<input type="text" name="otpsecret" class="w-full p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-blue-500 text-sm text-gray-900 dark:text-white" value="{{ otp.otpsecret }}" readonly>
</div>
<div class="flex flex-col gap-3 lg:flex-auto">
<label class="w-full text-sm font-medium text-gray-900 dark:text-white">
TOTP Code:
</label>
<input type="text" name="otpcode" class="w-full p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-blue-500 text-sm text-gray-900 dark:text-white" placeholder="123456">
</div>
</div>
<div class="flex flex-auto"></div>
<div class="flex w-full justify-end gap-4">
<button type="submit" class="py-3 px-8 text-sm text-left rounded-lg text-blue-700 dark:text-blue-300 hover:text-gray-100 bg-gray-100 dark:bg-gray-600 hover:bg-blue-500">
Validate
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
<!-- / -->
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.js"></script>
{% endblock %}