Initial app

This commit is contained in:
Martin Wittwer 2026-06-20 16:36:20 +02:00
parent e9852f9179
commit 6bc3f0cf45
11 changed files with 1755 additions and 1 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
VITE_KANIDM_BASE_URL=https://kanidm.example.com

3
.gitignore vendored
View file

@ -9,3 +9,6 @@ docs/_book
# TODO: where does this rule come from? # TODO: where does this rule come from?
test/ test/
node_modules/
dist/
.env

View file

@ -1,3 +1,51 @@
# kanidm-up-pass # kanidm-up-pass
A simple GUI to reset Kanidm User & Posix password at the same time A small Vue interface to reset Kanidm user and POSIX passwords to the same value from a credential reset token.
## Run
```bash
npm install
npm run dev
```
Build for production:
```bash
npm run build
```
## Usage
1. Open the app with a reset token in the URL:
`http://localhost:5173/?token=<credential-update-intent-token>`
2. Enter your new password and confirm it.
3. Submit.
The app connects directly to Kanidm:
1. `POST /v1/credential/_exchange_intent` with the token.
2. `POST /v1/credential/_update` with `password`.
3. `POST /v1/credential/_update` with `unixpassword`.
4. `POST /v1/credential/_commit`.
The UI supports English and German and lets users switch language in the page header.
## Configuration
Set your Kanidm base URL in `.env`:
```bash
cp .env.example .env
```
Example:
```bash
VITE_KANIDM_BASE_URL=https://kanidm.example.com
```
## Notes
- The token is read from the URL and then removed from the browser address bar.
- No backend is used; browser CORS access to Kanidm must be allowed for your app origin.

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanidm Password Reset</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1296
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "kanidm-up-pass",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.18",
"vue-i18n": "^11.4.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^5.4.19"
}
}

179
src/App.vue Normal file
View file

@ -0,0 +1,179 @@
<script setup>
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { supportedLocales } from "./i18n";
const baseUrl = (import.meta.env.VITE_KANIDM_BASE_URL || "").replace(/\/+$/, "");
const customName = (import.meta.env.VITE_KANIDM_NAME || "").trim();
const { t, locale } = useI18n();
const token = ref(readTokenFromUrl());
const password = ref("");
const confirmPassword = ref("");
const busy = ref(false);
const error = ref("");
const success = ref("");
const tokenPresent = computed(() => token.value.length > 0);
const canSubmit = computed(
() =>
tokenPresent.value &&
password.value.length > 0 &&
confirmPassword.value.length > 0 &&
!busy.value
);
function readTokenFromUrl() {
const url = new URL(window.location.href);
const value = (url.searchParams.get("token") || "").trim();
if (value) {
url.searchParams.delete("token");
window.history.replaceState({}, document.title, url.toString());
}
return value;
}
async function postJson(path, body, contentType = "application/json") {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": contentType,
Accept: "application/json"
},
body: contentType === "application/json" ? JSON.stringify(body) : body
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed (${response.status})`);
}
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function parseExchangeResponse(data) {
if (Array.isArray(data) && data.length >= 1 && data[0]?.token) {
return data[0].token;
}
if (data?.session_token?.token) {
return data.session_token.token;
}
if (data?.token) {
return data.token;
}
throw new Error(t("errUnexpectedExchange"));
}
async function submit() {
error.value = "";
success.value = "";
if (!baseUrl) {
error.value = t("errMissingBaseUrl");
return;
}
if (!tokenPresent.value) {
error.value = t("errMissingToken");
return;
}
if (password.value !== confirmPassword.value) {
error.value = t("errPasswordsMismatch");
return;
}
busy.value = true;
try {
const exchangeResponse = await postJson(
"/v1/credential/_exchange_intent",
token.value,
"text/plain"
);
const sessionToken = parseExchangeResponse(exchangeResponse);
await postJson("/v1/credential/_update", [
{ password: password.value },
{ token: sessionToken }
]);
await postJson("/v1/credential/_update", [
{ unixpassword: password.value },
{ token: sessionToken }
]);
await postJson("/v1/credential/_commit", { token: sessionToken });
password.value = "";
confirmPassword.value = "";
success.value = t("successReset");
} catch (e) {
error.value = e instanceof Error ? e.message : t("errResetFailed");
} finally {
busy.value = false;
}
}
function setLocale(nextLocale) {
if (!supportedLocales.includes(nextLocale)) return;
locale.value = nextLocale;
localStorage.setItem("locale", nextLocale);
}
</script>
<template>
<main class="card">
<div class="toolbar">
<h1>{{ customName || t("title") }}</h1>
<label class="locale-field" for="locale-select">
<span>{{ t("language") }}</span>
<select
id="locale-select"
:value="locale"
@change="setLocale($event.target.value)"
>
<option value="en">{{ t("languageEnglish") }}</option>
<option value="de">{{ t("languageGerman") }}</option>
</select>
</label>
</div>
<p>{{ t("subtitle") }}</p>
<template v-if="!tokenPresent">
<div class="message error">
{{ t("missingTokenHelp") }}
</div>
</template>
<template v-else>
<form @submit.prevent="submit">
<label for="password">{{ t("newPassword") }}</label>
<input
id="password"
v-model="password"
autocomplete="new-password"
type="password"
required
/>
<label for="confirm">{{ t("confirmPassword") }}</label>
<input
id="confirm"
v-model="confirmPassword"
autocomplete="new-password"
type="password"
required
/>
<button :disabled="!canSubmit">
{{ busy ? t("resetting") : t("resetPassword") }}
</button>
</form>
</template>
<div v-if="error" class="message error">{{ error }}</div>
<div v-if="success" class="message ok">{{ success }}</div>
<p class="hint">{{ t("targetApi") }}: {{ baseUrl || t("notConfigured") }}</p>
</main>
</template>

65
src/i18n.js Normal file
View file

@ -0,0 +1,65 @@
import { createI18n } from "vue-i18n";
export const supportedLocales = ["en", "de"];
const messages = {
en: {
language: "Language",
languageEnglish: "English",
languageGerman: "Deutsch",
title: "IDM password reset",
subtitle: "Reset your password.",
missingTokenHelp: "Missing reset token. Open this page with ?token=...",
newPassword: "New password",
confirmPassword: "Confirm password",
resetting: "Resetting...",
resetPassword: "Reset password",
targetApi: "Target API",
notConfigured: "not configured",
errMissingBaseUrl: "VITE_KANIDM_BASE_URL is not configured.",
errMissingToken: "Reset token is missing.",
errPasswordsMismatch: "Passwords do not match.",
errUnexpectedExchange: "Unexpected Kanidm exchange response.",
successReset: "Password reset succeeded.",
errResetFailed: "Reset failed."
},
de: {
language: "Sprache",
languageEnglish: "English",
languageGerman: "Deutsch",
title: "IDM Passwortreset",
subtitle: "Setzen Sie Ihr Passwort zurück.",
missingTokenHelp:
"Fehlender Reset-Token. Öffnen Sie diese Seite mit ?token=...",
newPassword: "Neues Passwort",
confirmPassword: "Passwort bestätigen",
resetting: "Wird zurückgesetzt...",
resetPassword: "Passwort zurücksetzen",
targetApi: "Ziel-API",
notConfigured: "nicht konfiguriert",
errMissingBaseUrl: "VITE_KANIDM_BASE_URL ist nicht konfiguriert.",
errMissingToken: "Reset-Token fehlt.",
errPasswordsMismatch: "Passwörter stimmen nicht überein.",
errUnexpectedExchange: "Unerwartete Kanidm-Antwort beim Token-Austausch.",
successReset:
"Passwort wurde erfolgreich zurückgesetzt.",
errResetFailed: "Zurücksetzen fehlgeschlagen."
}
};
function detectLocale() {
const saved = localStorage.getItem("locale");
if (saved && supportedLocales.includes(saved)) {
return saved;
}
const language = (navigator.language || "en").split("-")[0];
return supportedLocales.includes(language) ? language : "en";
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: "en",
messages
});

6
src/main.js Normal file
View file

@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
import { i18n } from "./i18n";
createApp(App).use(i18n).mount("#app");

119
src/styles.css Normal file
View file

@ -0,0 +1,119 @@
:root {
color-scheme: light dark;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #111827;
}
.card {
width: min(92vw, 420px);
border-radius: 12px;
padding: 1.25rem;
background: #1f2937;
color: #f9fafb;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
h1 {
margin: 0 0 1rem;
font-size: 1.2rem;
}
.toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.toolbar h1 {
margin: 0;
}
.locale-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
font-size: 0.8rem;
color: #d1d5db;
}
.locale-field select {
min-width: 7.5rem;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.35rem 0.45rem;
background: #111827;
color: #f9fafb;
}
p {
margin: 0 0 0.9rem;
color: #d1d5db;
}
label {
display: block;
margin: 0.75rem 0 0.35rem;
font-size: 0.95rem;
}
input {
width: 100%;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.65rem 0.75rem;
background: #111827;
color: #f9fafb;
}
button {
margin-top: 1rem;
width: 100%;
border: 0;
border-radius: 8px;
padding: 0.7rem 0.9rem;
background: #2563eb;
color: #fff;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.message {
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
border-radius: 8px;
font-size: 0.92rem;
}
.error {
background: #7f1d1d;
color: #fee2e2;
}
.ok {
background: #14532d;
color: #dcfce7;
}
.hint {
margin-top: 0.8rem;
font-size: 0.85rem;
color: #93c5fd;
}

6
vite.config.js Normal file
View file

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()]
});