Initial app
This commit is contained in:
parent
e9852f9179
commit
6bc3f0cf45
11 changed files with 1755 additions and 1 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_KANIDM_BASE_URL=https://kanidm.example.com
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
50
README.md
50
README.md
|
|
@ -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
12
index.html
Normal 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
1296
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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
179
src/App.vue
Normal 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
65
src/i18n.js
Normal 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
6
src/main.js
Normal 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
119
src/styles.css
Normal 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
6
vite.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()]
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue