feat(nextcloud): add Nextcloud-compatible API layer
Implement a complete Nextcloud client compatibility layer so that Nextcloud desktop/mobile sync clients can connect to OxiCloud. Key additions: - Login Flow v2 (device auth) with OIDC bridge support - WebDAV handler compatible with Nextcloud clients (PROPFIND, GET, PUT, DELETE, MKCOL, MOVE, COPY, HEAD, PROPPATCH) - OCS API endpoints (user info, capabilities, notifications stubs, sharees, unified search) - Basic Auth middleware with app password verification, account lockout integration, and blake3-keyed auth cache - App password management: create, list, revoke via both native API (JWT-authenticated profile page) and Nextcloud OCS endpoints - Nextcloud file ID mapping (oc:fileid) with persistent DB storage - Chunked upload support (Nextcloud v2 chunking protocol) - Trashbin WebDAV interface - Avatar (SVG placeholder) and preview (redirect) handlers - User profile page with app password management UI - URL user validation on all DAV routes (403 on mismatch) - Database schema for app_passwords and nextcloud_object_ids tables All services are behind a `nextcloud.enabled` config flag and cleanly separated under src/interfaces/nextcloud/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}
|
||||
body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-direction:column}
|
||||
body{background:#f5f7fa;color:#1e293b;min-height:100vh;height:auto;display:flex;flex-direction:column;overflow:auto}
|
||||
|
||||
.link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px}
|
||||
.width-zero{width:0%}
|
||||
@@ -116,6 +116,51 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
|
||||
#auth-error a{display:inline-flex;align-items:center;gap:6px;padding:10px 24px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:14px;box-shadow:0 3px 12px rgba(255,94,58,.3);transition:all .2s}
|
||||
#auth-error a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
|
||||
|
||||
/* ── App Passwords ── */
|
||||
.app-pw-desc{font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.5}
|
||||
.app-pw-create{display:flex;gap:10px;margin-bottom:16px}
|
||||
.app-pw-create input{
|
||||
flex:1;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;
|
||||
background:#f8fafc;transition:all .2s;font-family:inherit;color:#1e293b;
|
||||
}
|
||||
.app-pw-create input:focus{outline:none;border-color:#ff5e3a;background:#fff;box-shadow:0 0 0 3px rgba(255,94,58,.1)}
|
||||
.app-pw-created{background:#ecfdf5;border:1px solid #a7f3d0;border-radius:12px;padding:16px;margin-bottom:16px}
|
||||
.app-pw-created-label{font-size:13px;color:#065f46;margin-bottom:8px;font-weight:500}
|
||||
.app-pw-created-value{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
||||
.app-pw-created-value code{
|
||||
font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;
|
||||
font-size:16px;font-weight:700;color:#065f46;letter-spacing:1px;
|
||||
background:#d1fae5;padding:8px 14px;border-radius:8px;flex:1;word-break:break-all;
|
||||
}
|
||||
.btn-copy{
|
||||
padding:8px 12px;border:none;border-radius:8px;background:#059669;color:#fff;
|
||||
cursor:pointer;font-size:14px;transition:all .15s;flex-shrink:0;
|
||||
}
|
||||
.btn-copy:hover{background:#047857}
|
||||
.app-pw-created small{font-size:12px;color:#047857}
|
||||
.app-pw-table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
.app-pw-table thead th{text-align:left;font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:700;padding:8px 12px;border-bottom:1px solid #e2e8f0}
|
||||
.app-pw-table tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;color:#334155}
|
||||
.app-pw-table tbody tr:last-child td{border-bottom:none}
|
||||
.btn-danger-sm{
|
||||
padding:6px 10px;border:none;border-radius:8px;background:#fef2f2;color:#dc2626;
|
||||
cursor:pointer;font-size:13px;transition:all .15s;
|
||||
}
|
||||
.btn-danger-sm:hover{background:#fee2e2;color:#b91c1c}
|
||||
.app-pw-empty{text-align:center;color:#94a3b8;font-size:14px;padding:24px 0}
|
||||
.app-pw-auto-section{margin-top:20px;border-top:1px solid #e2e8f0;padding-top:16px}
|
||||
.app-pw-auto-toggle{
|
||||
display:flex;align-items:center;gap:8px;background:none;border:none;cursor:pointer;
|
||||
font-size:14px;font-weight:600;color:#64748b;padding:0;transition:color .15s;width:100%;
|
||||
}
|
||||
.app-pw-auto-toggle:hover{color:#334155}
|
||||
.app-pw-auto-toggle i{font-size:11px;transition:transform .15s;width:12px}
|
||||
.app-pw-auto-count{
|
||||
font-size:11px;font-weight:700;background:#e2e8f0;color:#64748b;
|
||||
padding:2px 8px;border-radius:10px;margin-left:auto;
|
||||
}
|
||||
.app-pw-auto-desc{font-size:12px;color:#94a3b8;margin:12px 0 8px;line-height:1.4}
|
||||
|
||||
/* ── Dark Mode ── */
|
||||
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
|
||||
@@ -146,3 +191,20 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
|
||||
[data-theme="dark"] #auth-error h2{color:#fca5a5}
|
||||
[data-theme="dark"] #auth-error p{color:#94a3b8}
|
||||
[data-theme="dark"] #loading{color:#64748b}
|
||||
[data-theme="dark"] .app-pw-desc{color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-create input{background:#0f172a;border-color:#334155;color:#e2e8f0}
|
||||
[data-theme="dark"] .app-pw-create input:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
|
||||
[data-theme="dark"] .app-pw-created{background:#052e16;border-color:#065f46}
|
||||
[data-theme="dark"] .app-pw-created-label{color:#86efac}
|
||||
[data-theme="dark"] .app-pw-created-value code{background:#064e3b;color:#86efac}
|
||||
[data-theme="dark"] .app-pw-created small{color:#6ee7b7}
|
||||
[data-theme="dark"] .app-pw-table thead th{color:#64748b;border-bottom-color:#334155}
|
||||
[data-theme="dark"] .app-pw-table tbody td{color:#e2e8f0;border-bottom-color:#1e293b}
|
||||
[data-theme="dark"] .btn-danger-sm{background:#3b1111;color:#fca5a5}
|
||||
[data-theme="dark"] .btn-danger-sm:hover{background:#501111;color:#fecaca}
|
||||
[data-theme="dark"] .app-pw-empty{color:#64748b}
|
||||
[data-theme="dark"] .app-pw-auto-section{border-top-color:#334155}
|
||||
[data-theme="dark"] .app-pw-auto-toggle{color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-auto-toggle:hover{color:#e2e8f0}
|
||||
[data-theme="dark"] .app-pw-auto-count{background:#334155;color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-auto-desc{color:#64748b}
|
||||
|
||||
+3
-1
@@ -753,7 +753,9 @@ const ui = {
|
||||
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||
}
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
// But NOT image files - those should be previewed in the inline viewer
|
||||
const isImage = file.mime_type && file.mime_type.startsWith('image/');
|
||||
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const contextMenus = {
|
||||
if (!wopiEdit || !wopiEditTab) return;
|
||||
|
||||
const targetFile = window.app && window.app.contextMenuTargetFile;
|
||||
// Don't show WOPI editor for image files - they should use inline preview
|
||||
const isImage = targetFile && targetFile.mime_type && targetFile.mime_type.startsWith('image/');
|
||||
const show = targetFile &&
|
||||
!isImage &&
|
||||
window.wopiEditor &&
|
||||
await window.wopiEditor.canEdit(targetFile.name);
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ class InlineViewer {
|
||||
console.log('Opening file:', file);
|
||||
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
// But NOT image files - those should be previewed in the inline viewer
|
||||
const isImage = file.mime_type && file.mime_type.startsWith('image/');
|
||||
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ async function init() {
|
||||
document.getElementById('password-section').style.display = 'none';
|
||||
}
|
||||
|
||||
loadAppPasswords();
|
||||
|
||||
try {
|
||||
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
|
||||
if (oidcResp.ok) {
|
||||
@@ -134,6 +136,150 @@ async function changePassword(e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── App Passwords ──
|
||||
|
||||
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
|
||||
|
||||
function isAutoPassword(pw) {
|
||||
return AUTO_LABELS.includes(pw.label);
|
||||
}
|
||||
|
||||
function renderPwRow(pw) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = document.createElement('td');
|
||||
label.textContent = pw.label;
|
||||
const created = document.createElement('td');
|
||||
created.textContent = new Date(pw.created_at).toLocaleDateString();
|
||||
const lastUsed = document.createElement('td');
|
||||
lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : 'Never';
|
||||
const actions = document.createElement('td');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-danger-sm';
|
||||
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||
btn.title = 'Revoke';
|
||||
btn.onclick = function () { revokeAppPassword(pw.id, pw.label); };
|
||||
actions.appendChild(btn);
|
||||
tr.append(label, created, lastUsed, actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadAppPasswords() {
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
|
||||
if (!resp.ok) {
|
||||
document.getElementById('app-passwords-section').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const passwords = await resp.json();
|
||||
const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); });
|
||||
const autoPws = passwords.filter(isAutoPassword);
|
||||
|
||||
// User-created passwords
|
||||
const tbody = document.getElementById('app-pw-tbody');
|
||||
const table = document.getElementById('app-pw-table');
|
||||
const empty = document.getElementById('app-pw-empty');
|
||||
tbody.innerHTML = '';
|
||||
if (userPws.length === 0) {
|
||||
table.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
} else {
|
||||
table.style.display = '';
|
||||
empty.style.display = 'none';
|
||||
for (const pw of userPws) tbody.appendChild(renderPwRow(pw));
|
||||
}
|
||||
|
||||
// Auto-generated (client session) passwords
|
||||
const autoSection = document.getElementById('app-pw-auto-section');
|
||||
if (autoPws.length === 0) {
|
||||
autoSection.style.display = 'none';
|
||||
} else {
|
||||
autoSection.style.display = '';
|
||||
document.getElementById('app-pw-auto-count').textContent = autoPws.length;
|
||||
const autoTbody = document.getElementById('app-pw-auto-tbody');
|
||||
autoTbody.innerHTML = '';
|
||||
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load app passwords', e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoPasswords() {
|
||||
const body = document.getElementById('app-pw-auto-body');
|
||||
const chevron = document.getElementById('app-pw-auto-chevron');
|
||||
const open = body.style.display === 'none';
|
||||
body.style.display = open ? '' : 'none';
|
||||
chevron.className = open ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
|
||||
}
|
||||
|
||||
async function createAppPassword() {
|
||||
const labelInput = document.getElementById('app-pw-label');
|
||||
const label = labelInput.value.trim();
|
||||
const statusEl = document.getElementById('app-pw-status');
|
||||
const btn = document.getElementById('app-pw-generate');
|
||||
|
||||
if (!label) {
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Please enter a label</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating…';
|
||||
statusEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ label: label })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to create app password') + '</div>';
|
||||
return;
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('app-pw-created-label').textContent = result.label;
|
||||
document.getElementById('app-pw-created-password').textContent = result.password;
|
||||
document.getElementById('app-pw-created').style.display = 'block';
|
||||
labelInput.value = '';
|
||||
loadAppPasswords();
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + err.message + '</div>';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-plus"></i> Generate';
|
||||
}
|
||||
}
|
||||
|
||||
function copyAppPassword() {
|
||||
const pw = document.getElementById('app-pw-created-password').textContent;
|
||||
navigator.clipboard.writeText(pw).then(function () {
|
||||
const btn = document.querySelector('.btn-copy');
|
||||
btn.innerHTML = '<i class="fas fa-check"></i>';
|
||||
setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeAppPassword(id, label) {
|
||||
if (!confirm('Revoke app password "' + label + '"? Clients using this password will stop working.')) return;
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
|
||||
method: 'DELETE',
|
||||
headers: headers()
|
||||
});
|
||||
if (resp.ok || resp.status === 204) {
|
||||
document.getElementById('app-pw-created').style.display = 'none';
|
||||
loadAppPasswords();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
alert(err.message || 'Failed to revoke app password');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
/* Wire up form handler (replaces inline onsubmit) */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title" id="error-title">Error</h2>
|
||||
<div class="auth-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span id="error-message">An error occurred. Please try again.</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="button" class="auth-button" id="error-action">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Read error type from URL query parameter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorType = params.get('type') || 'generic';
|
||||
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const errorAction = document.getElementById('error-action');
|
||||
|
||||
switch(errorType) {
|
||||
case 'invalid-credentials':
|
||||
errorTitle.textContent = 'Login Failed';
|
||||
errorMessage.textContent = 'Invalid username or password. Please check your credentials and try again.';
|
||||
errorAction.textContent = 'Try Again';
|
||||
errorAction.onclick = () => history.back();
|
||||
break;
|
||||
case 'session-expired':
|
||||
errorTitle.textContent = 'Session Expired';
|
||||
errorMessage.textContent = 'Your session has expired. Please try again.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
break;
|
||||
case 'not-found':
|
||||
errorTitle.textContent = 'Not Found';
|
||||
errorMessage.textContent = 'The requested page was not found.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
break;
|
||||
default:
|
||||
errorTitle.textContent = 'Error';
|
||||
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grant Access - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">Grant Access</h2>
|
||||
<p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;">
|
||||
A Nextcloud client is requesting access to your account.
|
||||
</p>
|
||||
|
||||
<form class="auth-form" method="POST" id="login-flow-form">
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="user">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
name="user"
|
||||
class="auth-input"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="auth-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-button" id="password-submit">Grant Access</button>
|
||||
</form>
|
||||
|
||||
<!-- OIDC/SSO login — shown only when OIDC is enabled -->
|
||||
<div id="oidc-section" style="display: none;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<span style="color: #9ca3af; font-size: 13px;">or</span>
|
||||
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||
</div>
|
||||
<button type="button" id="oidc-button" class="auth-button" style="background: #4f46e5;">
|
||||
Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Extract token from URL path and set form action
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const token = pathParts[pathParts.length - 1];
|
||||
// Validate token is hex-only to prevent injection
|
||||
if (!/^[0-9a-fA-F]+$/.test(token)) {
|
||||
document.body.innerHTML = '<p>Invalid session token.</p>';
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`;
|
||||
|
||||
// Check if OIDC is available and configure SSO button
|
||||
(async function() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/oidc/providers');
|
||||
if (!resp.ok) return;
|
||||
const info = await resp.json();
|
||||
if (!info.enabled) return;
|
||||
|
||||
// Show OIDC section
|
||||
const section = document.getElementById('oidc-section');
|
||||
section.style.display = 'block';
|
||||
|
||||
// Update button text with provider name
|
||||
const btn = document.getElementById('oidc-button');
|
||||
btn.textContent = `Sign in with ${info.provider_name || 'SSO'}`;
|
||||
|
||||
// If password login is disabled, hide the password form
|
||||
if (!info.password_login_enabled) {
|
||||
document.getElementById('login-flow-form').style.display = 'none';
|
||||
}
|
||||
|
||||
// SSO button redirects to the OIDC flow for this NC token
|
||||
btn.addEventListener('click', () => {
|
||||
window.location.href = `/login/v2/flow/${token}/oidc`;
|
||||
});
|
||||
} catch(e) {
|
||||
// OIDC not available — silently keep password-only mode
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Granted - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">Access Granted</h2>
|
||||
<div class="auth-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>You have successfully granted access to your account.</span>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||
You can now close this window and return to your Nextcloud app.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="button" class="auth-button" onclick="window.close()">Close Window</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-close after 3 seconds
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -94,6 +94,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card" id="app-passwords-section">
|
||||
<h2><i class="fas fa-key"></i> App Passwords</h2>
|
||||
<p class="app-pw-desc">Generate passwords for WebDAV, CalDAV, and CardDAV clients. Each password is shown only once.</p>
|
||||
|
||||
<div class="app-pw-create">
|
||||
<input type="text" id="app-pw-label" placeholder="Label (e.g. Thunderbird, macOS)" maxlength="128">
|
||||
<button class="btn btn-primary" id="app-pw-generate" onclick="createAppPassword()"><i class="fas fa-plus"></i> Generate</button>
|
||||
</div>
|
||||
|
||||
<div id="app-pw-created" class="app-pw-created" style="display:none">
|
||||
<div class="app-pw-created-label">New password for <strong id="app-pw-created-label"></strong>:</div>
|
||||
<div class="app-pw-created-value">
|
||||
<code id="app-pw-created-password"></code>
|
||||
<button class="btn btn-copy" onclick="copyAppPassword()" title="Copy to clipboard"><i class="fas fa-copy"></i></button>
|
||||
</div>
|
||||
<small>Copy this password now. You won't be able to see it again.</small>
|
||||
</div>
|
||||
|
||||
<div id="app-pw-status"></div>
|
||||
|
||||
<table class="app-pw-table" id="app-pw-table">
|
||||
<thead>
|
||||
<tr><th>Label</th><th>Created</th><th>Last Used</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="app-pw-tbody"></tbody>
|
||||
</table>
|
||||
<div id="app-pw-empty" class="app-pw-empty" style="display:none">No app passwords yet.</div>
|
||||
|
||||
<div id="app-pw-auto-section" class="app-pw-auto-section" style="display:none">
|
||||
<button class="app-pw-auto-toggle" id="app-pw-auto-toggle" onclick="toggleAutoPasswords()">
|
||||
<i class="fas fa-chevron-right" id="app-pw-auto-chevron"></i>
|
||||
<span>Client sessions</span>
|
||||
<span class="app-pw-auto-count" id="app-pw-auto-count">0</span>
|
||||
</button>
|
||||
<div id="app-pw-auto-body" style="display:none">
|
||||
<p class="app-pw-auto-desc">Auto-generated when you connect a Nextcloud-compatible client.</p>
|
||||
<table class="app-pw-table" id="app-pw-auto-table">
|
||||
<thead>
|
||||
<tr><th>Client</th><th>Created</th><th>Last Used</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="app-pw-auto-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card" id="password-section">
|
||||
<h2><i class="fas fa-key"></i> Change Password</h2>
|
||||
<form id="password-form">
|
||||
|
||||
Reference in New Issue
Block a user