Merge pull request 'Sync Feature: manual' (#2) from sync/manual-20260425-0644 into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-25 00:03:06 +00:00
13 changed files with 4131 additions and 0 deletions
+660
View File
@@ -0,0 +1,660 @@
/**
* SchoolHub Launcher - Backend Logic
* Core architecture: Google Sheets as Database
*/
const SPREADSHEET_ID = '1oWvHLC2tnLHHO36IxTErSCuvDWobsbI_LTlQwtMYiZU';
const APP_ID = 'launcher';
// --- CORE ENTRY POINT ---
function doGet() {
return HtmlService
.createTemplateFromFile('Index')
.evaluate()
.setTitle('SchoolHub Launcher')
.addMetaTag('viewport', 'width=device-width, initial-scale=1')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
// --- DATABASE SETUP ---
function setupDatabase() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheets = [
{ name: 'Users', headers: ['email', 'name', 'phone', 'status', 'unit', 'isAdmin', 'createdAt', 'updatedAt'] },
{ name: 'Access', headers: ['email', 'appId', 'roles', 'active', 'notes', 'updatedAt'] },
{ name: 'Apps', headers: ['appId', 'appName', 'appUrl', 'active', 'sortOrder'] },
{ name: 'AuditLog', headers: ['timestamp', 'email', 'appId', 'action', 'status', 'message', 'actor'] },
{ name: 'OTP', headers: ['phone', 'code', 'expiresAt'] },
{ name: 'Sessions', headers: ['sessionId', 'email', 'expiresAt'] },
{ name: 'SystemDocs', headers: ['docType', 'content', 'updatedAt'] }
];
sheets.forEach(({ name, headers }) => {
let sheet = ss.getSheetByName(name);
if (!sheet) sheet = ss.insertSheet(name);
// Always update headers to ensure schema is current
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
});
return { ok: true, message: 'Database setup complete. Sheets and headers initialized.' };
}
// --- JSON SERIALIZATION HELPERS ---
function serializeForClient(value) {
if (value === null || value === undefined) return null;
if (Object.prototype.toString.call(value) === '[object Date]') {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(serializeForClient);
}
if (typeof value === 'object') {
const out = {};
Object.keys(value).forEach((k) => {
out[k] = serializeForClient(value[k]);
});
return out;
}
return value;
}
// --- UTILITY HELPERS ---
function normalizeEmail(email) {
if (!email) return '';
return String(email).trim().toLowerCase();
}
function normalizeBoolString(value) {
return String(value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE';
}
function isTrue_(value) {
return String(value).toUpperCase() === 'TRUE';
}
function toSafeText_(value) {
return value === null || value === undefined ? '' : String(value).trim();
}
function getCurrentUserEmail() {
return normalizeEmail(Session.getActiveUser().getEmail());
}
function rowsToObjects_(values) {
if (!values || values.length < 2) return [];
const headers = values[0];
const out = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
const obj = {};
for (let c = 0; c < headers.length; c++) obj[headers[c]] = row[c];
out.push(obj);
}
return out;
}
// --- PRIVATE DATABASE READERS ---
function readUsers_(ss) {
const sheet = ss.getSheetByName('Users');
if (!sheet) return {};
const rows = rowsToObjects_(sheet.getDataRange().getValues());
const map = {};
rows.forEach((obj) => {
const email = normalizeEmail(obj.email);
if (email) map[email] = obj;
});
return map;
}
function readUsersByEmail_(ss, email) {
const users = readUsers_(ss);
return users[normalizeEmail(email)] || null;
}
function readApps_(ss) {
const sheet = ss.getSheetByName('Apps');
if (!sheet) return [];
return rowsToObjects_(sheet.getDataRange().getValues())
.filter(app => isTrue_(app.active))
.map(app => ({
appId: toSafeText_(app.appId),
appName: toSafeText_(app.appName),
appUrl: toSafeText_(app.appUrl),
active: true,
sortOrder: Number(app.sortOrder || 0)
}));
}
function readAccessRow_(ss, email, appId) {
const sheet = ss.getSheetByName('Access');
if (!sheet) return null;
const rows = rowsToObjects_(sheet.getDataRange().getValues());
return rows.find(r => normalizeEmail(r.email) === normalizeEmail(email) && String(r.appId) === String(appId)) || null;
}
function readAccessByEmail_(ss, email) {
const sheet = ss.getSheetByName('Access');
if (!sheet) return [];
return rowsToObjects_(sheet.getDataRange().getValues())
.filter(r => normalizeEmail(r.email) === normalizeEmail(email));
}
// --- AUTH & PERMISSIONS LOGIC ---
function getAuthCacheKey_(email, appId) {
return `auth_${appId}_${email}`;
}
function clearAuthCache_(email, appId = APP_ID) {
CacheService.getScriptCache().remove(getAuthCacheKey_(email, appId));
}
function isAllowed(email, appId) {
if (!email) return { isAllowed: false, reason: 'Email is missing.' };
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const ownerEmail = normalizeEmail(ss.getOwner().getEmail());
const users = readUsers_(ss);
let user = users[email];
// Auto-admin for owner
if (!user && email === ownerEmail) {
const userSheet = ss.getSheetByName('Users');
const now = new Date();
userSheet.appendRow([email, 'Owner Admin', '', 'active', 'System', 'TRUE', now, now]);
user = { email, status: 'active', isAdmin: 'TRUE' };
clearAuthCache_(email, appId);
}
if (!user) return { isAllowed: false, reason: 'Email not whitelisted.' };
if (String(user.status || '').toLowerCase() !== 'active') return { isAllowed: false, reason: 'Account is inactive.' };
if (isTrue_(user.isAdmin)) return { isAllowed: true, reason: '' };
if (appId === APP_ID) return { isAllowed: true, reason: '' };
const access = readAccessRow_(ss, email, appId);
if (!access || !isTrue_(access.active)) return { isAllowed: false, reason: 'No active access for this app.' };
return { isAllowed: true, reason: '' };
}
function getAuthContext(email, appId) {
const cache = CacheService.getScriptCache();
const cacheKey = getAuthCacheKey_(email, appId);
const cached = cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const allowed = isAllowed(email, appId);
if (!allowed.isAllowed) {
const denied = { isAllowed: false, reason: allowed.reason, roles: [] };
cache.put(cacheKey, JSON.stringify(denied), 300);
return denied;
}
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userRow = readUsersByEmail_(ss, email);
const accessRow = readAccessRow_(ss, email, appId);
const roles = accessRow && accessRow.roles
? String(accessRow.roles).split(',').map(r => r.trim()).filter(Boolean)
: (userRow && isTrue_(userRow.isAdmin) ? ['admin'] : []);
const result = {
isAllowed: true,
user: {
email: userRow ? userRow.email : email,
name: userRow ? (userRow.name || '') : '',
unit: userRow ? (userRow.unit || '') : ''
},
roles,
permissions: {
canManageUsers: roles.includes('admin') || (userRow && isTrue_(userRow.isAdmin)),
canEditApp: roles.includes('editor') || roles.includes('admin'),
canViewApp: true
}
};
cache.put(cacheKey, JSON.stringify(result), 300);
return result;
}
function getAllowedApps(email) {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const apps = readApps_(ss);
const user = readUsersByEmail_(ss, email);
if (user && isTrue_(user.isAdmin)) {
return apps.sort((a, b) => a.sortOrder - b.sortOrder);
}
const accessRows = readAccessByEmail_(ss, email);
const allowedIds = new Set(accessRows.filter(r => isTrue_(r.active)).map(r => String(r.appId)));
return apps
.filter(a => allowedIds.has(String(a.appId)))
.sort((a, b) => a.sortOrder - b.sortOrder);
}
// --- PUBLIC API: FRONTEND DATA ---
function getInitialData() {
try {
const email = getCurrentUserEmail();
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userSheet = ss.getSheetByName('Users');
const isSystemEmpty = !userSheet || userSheet.getLastRow() < 2;
if (isSystemEmpty) {
return { ok: true, data: serializeForClient({ isSystemEmpty: true, userEmail: email }) };
}
if (!email) return { ok: true, data: serializeForClient({ needsAuth: true }) };
const auth = getAuthContext(email, APP_ID);
if (!auth.isAllowed) {
logAccess({ email, appId: APP_ID, action: 'ACCESS_DENIED', status: 'FAILURE', message: auth.reason });
return { ok: false, message: auth.reason };
}
const apps = getAllowedApps(email);
logAccess({ email, appId: APP_ID, action: 'ACCESS_GRANTED', status: 'SUCCESS', message: 'User loaded launcher' });
return {
ok: true,
data: serializeForClient({
user: auth.user,
apps,
isAdmin: auth.roles.includes('admin')
})
};
} catch (e) {
return { ok: false, message: e.message };
}
}
function registerFirstAdmin(name) {
try {
const email = getCurrentUserEmail();
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Users');
if (sheet && sheet.getLastRow() >= 2) throw new Error('System already initialized.');
if (!email) throw new Error('User email not available.');
const now = new Date();
sheet.appendRow([email, toSafeText_(name), '', 'active', 'System Admin', 'TRUE', now, now]);
clearAuthCache_(email);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ADMIN USER MANAGEMENT ---
function getAdminUsers() {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const values = ss.getSheetByName('Users').getDataRange().getValues();
return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveUser(userData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const targetEmail = normalizeEmail(userData.email);
if (!targetEmail || !userData.name) throw new Error('Email and Name are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Users');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const emailIdx = headers.indexOf('email');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (normalizeEmail(values[i][emailIdx]) === targetEmail) {
rowIndex = i + 1;
break;
}
}
const now = new Date();
const rowValue = headers.map(h => {
if (h === 'email') return targetEmail;
if (h === 'name') return toSafeText_(userData.name);
if (h === 'status') return toSafeText_(userData.status).toLowerCase() === 'inactive' ? 'inactive' : 'active';
if (h === 'unit') return toSafeText_(userData.unit);
if (h === 'isAdmin') return normalizeBoolString(userData.isAdmin);
if (h === 'updatedAt') return now;
if (h === 'createdAt' && rowIndex > 0) return values[rowIndex - 1][headers.indexOf('createdAt')];
if (h === 'createdAt') return now;
return toSafeText_(userData[h]);
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
clearAuthCache_(targetEmail);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteUser(targetEmail) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userSheet = ss.getSheetByName('Users');
const accessSheet = ss.getSheetByName('Access');
const normalizedTarget = normalizeEmail(targetEmail);
const deleteByEmail = (sheet) => {
const data = sheet.getDataRange().getValues();
const emailIdx = data[0].indexOf('email');
for (let i = data.length - 1; i >= 1; i--) {
if (normalizeEmail(data[i][emailIdx]) === normalizedTarget) sheet.deleteRow(i + 1);
}
};
deleteByEmail(userSheet);
deleteByEmail(accessSheet);
clearAuthCache_(normalizedTarget);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ADMIN APP MANAGEMENT ---
function getAdminApps() {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const values = ss.getSheetByName('Apps').getDataRange().getValues();
return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveApp(appData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const appId = toSafeText_(appData.appId);
if (!appId || !appData.appName || !appData.appUrl) throw new Error('App ID, Name, and URL are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Apps');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const appIdIdx = headers.indexOf('appId');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (String(values[i][appIdIdx]) === appId) {
rowIndex = i + 1;
break;
}
}
const rowValue = headers.map(h => {
if (h === 'appId') return appId;
if (h === 'appName') return toSafeText_(appData.appName);
if (h === 'appUrl') return toSafeText_(appData.appUrl);
if (h === 'active') return normalizeBoolString(appData.active);
if (h === 'sortOrder') return Number(appData.sortOrder || 0);
return '';
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteApp(appId) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Apps');
const data = sheet.getDataRange().getValues();
const appIdIdx = data[0].indexOf('appId');
for (let i = data.length - 1; i >= 1; i--) {
if (String(data[i][appIdIdx]) === String(appId)) {
sheet.deleteRow(i + 1);
return { ok: true };
}
}
throw new Error('App not found');
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ACCESS MANAGEMENT ---
function getAccessForUser(email) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
return {
ok: true,
data: serializeForClient({
userAccess: readAccessByEmail_(ss, email),
availableApps: readApps_(ss)
})
};
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveAccess(accessData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const targetEmail = normalizeEmail(accessData.email);
const appId = toSafeText_(accessData.appId);
if (!targetEmail || !appId) throw new Error('Email and App ID are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Access');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const emailIdx = headers.indexOf('email');
const appIdIdx = headers.indexOf('appId');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (normalizeEmail(values[i][emailIdx]) === targetEmail && String(values[i][appIdIdx]) === appId) {
rowIndex = i + 1;
break;
}
}
const rowValue = headers.map(h => {
if (h === 'email') return targetEmail;
if (h === 'appId') return appId;
if (h === 'roles') return toSafeText_(accessData.roles);
if (h === 'active') return normalizeBoolString(accessData.active);
if (h === 'notes') return toSafeText_(accessData.notes);
if (h === 'updatedAt') return new Date();
return '';
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
clearAuthCache_(targetEmail);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteAccess(email, appId) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Access');
const data = sheet.getDataRange().getValues();
const emailIdx = data[0].indexOf('email');
const appIdIdx = data[0].indexOf('appId');
for (let i = data.length - 1; i >= 1; i--) {
if (normalizeEmail(data[i][emailIdx]) === normalizeEmail(email) && String(data[i][appIdIdx]) === String(appId)) {
sheet.deleteRow(i + 1);
}
}
clearAuthCache_(email);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: AUTHENTICATION & OTP ---
const GO_WHATSAPP_API_KEY = 'YOUR_API_KEY';
const GO_WHATSAPP_URL = 'https://api.gowhatsapp.com/send';
function sendOTP(phone) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
if (!users.find(u => String(u.phone).trim() === String(phone).trim())) {
throw new Error('Nomor WhatsApp Anda belum terdaftar.');
}
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const otpSheet = ss.getSheetByName('OTP');
const otpData = otpSheet.getDataRange().getValues();
for (let i = otpData.length - 1; i >= 1; i--) {
if (otpData[i][0] === phone) otpSheet.deleteRow(i + 1);
}
otpSheet.appendRow([phone, code, expiresAt]);
UrlFetchApp.fetch(GO_WHATSAPP_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({
apiKey: GO_WHATSAPP_API_KEY,
phone: phone,
message: `Kode OTP SchoolHub Anda adalah: ${code}. Berlaku selama 5 menit.`
}),
muteHttpExceptions: true
});
return { ok: true, message: 'OTP telah dikirim ke WhatsApp Anda.' };
} catch (e) {
return { ok: false, message: e.message };
}
}
function verifyOTP(phone, code) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const otpData = ss.getSheetByName('OTP').getDataRange().getValues();
const validOtp = otpData.find(r => r[0] === phone && String(r[1]) === String(code) && new Date(r[2]) > new Date());
if (!validOtp) throw new Error('Kode OTP salah atau kadaluwarsa.');
const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
const user = users.find(u => String(u.phone).trim() === String(phone).trim());
if (!user) throw new Error('User tidak ditemukan.');
const sessionId = Utilities.getUuid();
ss.getSheetByName('Sessions').appendRow([sessionId, user.email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
return { ok: true, data: { sessionId, email: user.email } };
} catch (e) {
return { ok: false, message: e.message };
}
}
function loginWithGoogle() {
try {
const email = getCurrentUserEmail();
if (!email) return { ok: false, message: 'Email tidak terdeteksi.' };
const auth = isAllowed(email, APP_ID);
if (!auth.isAllowed) return { ok: false, message: auth.reason };
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sessionSheet = ss.getSheetByName('Sessions');
if (!sessionSheet) {
throw new Error('System Error: "Sessions" sheet not found. Please run setupDatabase().');
}
const sessionId = Utilities.getUuid();
sessionSheet.appendRow([sessionId, email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
return { ok: true, data: { sessionId, email } };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- LOGGING ---
function logAccess(event) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('AuditLog');
if (!sheet) return;
sheet.appendRow([
new Date(),
event.email || '',
event.appId || '',
event.action || '',
event.status || '',
event.message || '',
event.actor || 'System'
]);
} catch (e) {
console.error('Logging failed: ' + e.message);
}
}
+912
View File
@@ -0,0 +1,912 @@
<!DOCTYPE html>
<html lang="id">
<head>
<base target="_top" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/framework7@8/framework7-bundle.min.css"
/>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x/dist/cdn.min.js"></script>
<?!= HtmlService.createHtmlOutputFromFile('styles').getContent(); ?>
</head>
<body class="text-slate-900 font-['Roboto'] antialiased">
<div x-data="appLauncher()" x-init="init()" x-cloak id="app-wrapper">
<!-- Login Overlay -->
<template x-if="!authenticated">
<div class="fixed inset-0 z-[2000] bg-slate-50 flex items-center justify-center p-6">
<div class="max-w-sm w-full bg-white p-8 rounded-3xl shadow-xl border border-slate-100 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 text-primary mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 class="text-2xl font-['Poppins'] font-bold text-slate-900 mb-2">Welcome Back</h2>
<p class="text-slate-500 mb-8 text-sm">Silakan masuk untuk mengakses dashboard sekolah</p>
<div class="space-y-4">
<!-- Google Login -->
<button @click="doGoogleLogin()" :disabled="loginLoading" class="w-full py-3 px-4 bg-white border border-slate-200 rounded-xl flex items-center justify-center gap-3 font-medium text-slate-700 hover:bg-slate-50 transition-all disabled:opacity-50">
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Lanjutkan dengan Google
</button>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center"><span class="w-full border-t border-slate-200"></span></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-semibold">Atau</span></div>
</div>
<!-- WA OTP Login -->
<div x-show="loginMethod === 'google'">
<button @click="loginMethod = 'wa'" class="w-full py-2 text-sm text-primary font-medium hover:underline">Masuk dengan WhatsApp OTP</button>
</div>
<div x-show="loginMethod === 'wa'" class="space-y-3">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">+62</span>
<input type="tel" x-model="authPhone" placeholder="8123456789" class="w-full pl-11 pr-3 py-3 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-sm">
</div>
<template x-if="!otpSent">
<button @click="sendOTP()" :disabled="loginLoading" class="w-full py-3 bg-green-500 text-white rounded-xl font-semibold hover:bg-green-600 transition-all disabled:opacity-50">
Kirim Kode OTP
</button>
</template>
<template x-if="otpSent">
<div class="space-y-3">
<input type="text" x-model="authCode" placeholder="Masukkan 6 Digit Kode" maxlength="6" class="w-full px-3 py-3 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-center text-lg font-bold tracking-widest">
<button @click="verifyOTP()" :disabled="loginLoading" class="w-full py-3 bg-primary text-white rounded-xl font-semibold hover:bg-blue-600 transition-all disabled:opacity-50">
Verifikasi & Masuk
</button>
<button @click="otpSent = false" class="text-xs text-slate-400 hover:underline">Ganti Nomor</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<div x-show="authenticated" x-cloak id="app" class="view view-main h-full">
=======
<div class="page h-full flex flex-col">
<!-- Navbar -->
<div class="navbar">
<div class="navbar-bg"></div>
<div class="navbar-inner px-6 flex items-center justify-between">
<div class="left flex flex-col">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none mb-1">
SchoolHub
</span>
<h1
class="font-['Poppins'] text-xl font-bold text-slate-900 leading-none"
x-text="view === 'home' ? 'Dashboard' : (view === 'users' ? 'Community' : 'System Apps')"
></h1>
</div>
<div class="right flex items-center gap-3">
<template x-if="user">
<button
type="button"
@click="exitApp()"
class="h-10 w-10 rounded-2xl bg-white border border-slate-100 flex items-center justify-center text-red-500 shadow-sm hover:bg-red-50 transition-colors"
aria-label="Keluar dari aplikasi"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</template>
</div>
</div>
</div>
<!-- Floating Navigation -->
<template x-if="isAdmin && !loading && !isSystemEmpty">
<nav class="floating-nav" aria-label="Admin navigation">
<button type="button" @click="view = 'home'" class="nav-item" :class="view === 'home' ? 'active' : ''" aria-label="Dashboard">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</button>
<button type="button" @click="view = 'manage'" class="nav-item" :class="view === 'manage' ? 'active' : ''" aria-label="Kelola aplikasi">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button type="button" @click="view = 'users'" class="nav-item" :class="view === 'users' ? 'active' : ''" aria-label="Kelola pengguna">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
</nav>
</template>
<!-- Page Content -->
<div class="page-content bg-slate-50 px-4">
<div class="pb-10">
<!-- Loading -->
<template x-if="loading">
<div class="flex flex-col items-center justify-center py-20">
<div class="progress-ring-container">
<svg class="progress-ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="progress-ring-bg" cx="60" cy="60" r="54" fill="none" stroke="#e2e8f0" stroke-width="8"></circle>
<circle
class="progress-ring-circle"
cx="60"
cy="60"
r="54"
fill="none"
stroke="#2481cc"
stroke-width="8"
stroke-linecap="round"
:style="{ strokeDashoffset: 339.292 - (339.292 * loadingProgress / 100) }"
></circle>
</svg>
<div class="progress-ring-text">
<span class="text-2xl font-bold text-slate-900" x-text="loadingProgress + '%'"></span>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-sm font-medium text-slate-600" x-text="loadingMessage"></p>
<div class="mt-3 flex gap-1 justify-center">
<template x-for="n in 6" :key="n">
<div class="w-2 h-2 rounded-full transition-all" :class="loadingStage >= n ? 'bg-primary' : 'bg-slate-200'"></div>
</template>
</div>
</div>
</div>
</template>
<!-- Initial Setup -->
<template x-if="!loading && isSystemEmpty">
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-sm border border-slate-200">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-10V4a1 1 0 011-1h2a1 1 0 011 1v3M12 15h.01M12 19h.01M12 7h.01M12 11h.01" />
</svg>
</div>
<h2 class="text-2xl font-['Poppins'] font-semibold">Welcome to SchoolHub</h2>
<p class="text-slate-500 mt-2">No users found. Register yourself as the first Administrator.</p>
</div>
<form @submit.prevent="registerFirstAdmin()">
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Your Email</label>
<input type="text" :value="userEmail" disabled class="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-500 outline-none" />
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Your Full Name</label>
<input type="text" x-model="setupName" required class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" placeholder="Enter your name" />
</div>
<button type="submit" :disabled="saving" class="w-full py-3 bg-primary text-white rounded-lg font-semibold hover:bg-blue-600 transition-colors disabled:opacity-50">
<span x-show="!saving">Initialize System</span>
<span x-show="saving">Setting up...</span>
</button>
</div>
</form>
</div>
</template>
<!-- Needs Auth -->
<template x-if="!loading && needsAuth">
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-sm border border-slate-200 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 text-blue-600 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h2 class="text-xl font-semibold mb-2">Identitas Tidak Terdeteksi</h2>
<p class="text-slate-600 mb-6 text-sm">Google tidak memberikan informasi akun Anda. Pastikan Anda telah login dan memberikan izin akses aplikasi.</p>
<div class="bg-amber-50 border border-amber-100 rounded-lg p-4 mb-6 text-left">
<p class="text-xs text-amber-800 font-medium mb-1">Tips Pengujian:</p>
<ul class="text-[10px] text-amber-700 list-disc ml-4 space-y-1">
<li>Gunakan <b>Incognito Window</b>.</li>
<li>Pastikan hanya 1 akun Google yang aktif di browser.</li>
<li>Klik tombol di bawah untuk muat ulang.</li>
</ul>
</div>
<button type="button" @click="window.top.location.reload()" class="w-full py-3 bg-primary text-white rounded-lg font-semibold hover:bg-blue-600 transition-colors">
Muat Ulang Aplikasi
</button>
</div>
</template>
<!-- Error -->
<template x-if="!loading && error && !needsAuth">
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-sm border border-red-100 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-50 text-red-600 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 class="text-xl font-semibold mb-2">Access Restricted</h2>
<p class="text-slate-600 mb-6" x-text="error"></p>
<button type="button" @click="window.top.location.reload()" class="w-full py-2 px-4 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors font-medium">
Try Again
</button>
</div>
</template>
<!-- Home -->
<template x-if="!loading && !error && view === 'home'">
<div>
<div class="mb-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-['Poppins'] font-medium">Your Applications</h2>
<div class="flex items-center gap-1 bg-slate-100 p-1 rounded-lg" aria-label="Layout mode">
<button type="button" @click="layoutMode='grid'" :class="{'bg-white shadow-sm text-primary': layoutMode==='grid'}" class="p-2 rounded-md text-slate-400 hover:text-primary transition-colors" title="Grid View">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button type="button" @click="layoutMode='bento'" :class="{'bg-white shadow-sm text-primary': layoutMode==='bento'}" class="p-2 rounded-md text-slate-400 hover:text-primary transition-colors" title="Bento View">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 13a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z" />
</svg>
</button>
</div>
</div>
</div>
<template x-if="layoutMode === 'grid'">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="app in apps" :key="app.appId">
<div @click="openApp(app.appUrl)" class="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md hover:border-primary/30 transition-all cursor-pointer" :title="'Buka ' + app.appName">
<div class="flex items-start justify-between mb-4">
<div class="h-12 w-12 rounded-xl bg-primary/5 text-primary flex items-center justify-center group-hover:bg-primary group-hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-300 group-hover:text-primary transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<h3 class="font-semibold text-lg" x-text="app.appName"></h3>
</div>
</template>
</div>
</template>
<template x-if="layoutMode === 'bento'">
<div class="grid grid-cols-2 gap-4">
<template x-if="featuredApps.length > 0">
<div @click="openApp(featuredApps[0].appUrl)" class="group col-span-2 bg-gradient-to-br from-primary to-blue-600 p-6 rounded-2xl text-white shadow-lg hover:shadow-xl transition-all cursor-pointer relative overflow-hidden" :title="'Buka ' + featuredApps[0].appName">
<div class="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -mr-16 -mt-16"></div>
<div class="absolute bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full -ml-12 -mb-12"></div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-4">
<div class="h-14 w-14 rounded-xl bg-white/20 backdrop-blur-sm text-white flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="px-2 py-1 bg-white/20 rounded-lg text-[10px] font-bold uppercase tracking-wider">Featured</span>
</div>
<h3 class="font-['Poppins'] font-bold text-2xl mb-1" x-text="featuredApps[0].appName"></h3>
<p class="text-white/70 text-sm">Tap to open</p>
</div>
</div>
</template>
<template x-for="app in featuredApps.slice(1)" :key="app.appId">
<div @click="openApp(app.appUrl)" class="group bg-white p-5 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md hover:border-primary/30 transition-all cursor-pointer" :title="'Buka ' + app.appName">
<div class="flex items-start justify-between mb-3">
<div class="h-10 w-10 rounded-lg bg-primary/5 text-primary flex items-center justify-center group-hover:bg-primary group-hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<h3 class="font-semibold text-base" x-text="app.appName"></h3>
</div>
</template>
<template x-for="app in otherApps" :key="app.appId">
<div @click="openApp(app.appUrl)" class="group bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md hover:border-primary/30 transition-all cursor-pointer" :title="'Buka ' + app.appName">
<div class="flex items-start justify-between mb-2">
<div class="h-8 w-8 rounded-lg bg-primary/5 text-primary flex items-center justify-center group-hover:bg-primary group-hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<h3 class="font-medium text-sm" x-text="app.appName"></h3>
</div>
</template>
</div>
</template>
<template x-if="apps.length === 0">
<div class="text-center py-20 bg-white rounded-2xl border border-dashed border-slate-300">
<p class="text-slate-500">No applications assigned to your account.</p>
</div>
</template>
</div>
</template>
<!-- Users -->
<template x-if="!loading && view === 'users'">
<div x-init="fetchAdminUsers()">
<div class="category-bar">
<button type="button" @click="userCategory = 'all'" class="category-pill" :class="userCategory === 'all' ? 'active' : ''">
<span>Semua</span>
<span class="category-count" x-text="adminUsers.length"></span>
</button>
<button type="button" @click="userCategory = 'admin'" class="category-pill" :class="userCategory === 'admin' ? 'active' : ''">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.64.304 1.25.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span>Admin</span>
<span class="category-count" x-text="adminUsers.filter(u => u.isAdmin === 'TRUE' || u.isAdmin === true).length"></span>
</button>
<button type="button" @click="userCategory = 'active'" class="category-pill" :class="userCategory === 'active' ? 'active' : ''">
<span>Aktif</span>
<span class="category-count" x-text="adminUsers.filter(u => String(u.status || '').toLowerCase() === 'active').length"></span>
</button>
<button type="button" @click="userCategory = 'inactive'" class="category-pill" :class="userCategory === 'inactive' ? 'active' : ''">
<span>Nonaktif</span>
<span class="category-count" x-text="adminUsers.filter(u => String(u.status || '').toLowerCase() !== 'active').length"></span>
</button>
</div>
<template x-if="loadingUsers">
<div class="flex flex-col items-center justify-center py-10 bg-white rounded-xl border border-slate-200 shadow-sm">
<div class="relative w-16 h-16">
<svg class="w-16 h-16" viewBox="0 0 36 36">
<path class="text-slate-200" fill="none" stroke-width="3" stroke="currentColor" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="text-primary" fill="none" stroke-width="3" stroke-linecap="round" stroke="currentColor" :stroke-dasharray="loadingProgress + ', 100'" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-primary" x-text="loadingProgress + '%'"></div>
</div>
<p class="mt-3 text-xs text-slate-400 font-medium">Loading community...</p>
</div>
</template>
<div x-show="!loadingUsers" class="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm divide-y divide-slate-100">
<template x-for="u in filteredUsers" :key="u.email">
<div class="user-item p-4 flex items-center justify-between hover:bg-slate-50 transition-colors" @click="openUserModal(u)">
<div class="flex items-center gap-3 min-w-0">
<div
class="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center text-white font-semibold text-sm"
:class="String(u.status || '').toLowerCase() === 'active' ? 'bg-primary' : 'bg-slate-300'"
x-text="(u.name || '?').charAt(0).toUpperCase()"
></div>
<div class="min-w-0">
<div class="flex items-center gap-1">
<span class="font-semibold text-slate-900 truncate" x-text="u.name"></span>
<template x-if="u.isAdmin == 'TRUE' || u.isAdmin === true">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.64.304 1.25.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</template>
</div>
<div class="text-[11px] text-slate-400 truncate" x-text="u.unit || 'No Department'"></div>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" @click.stop="openAccessModal(u)" class="p-1.5 text-[10px] font-bold bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors uppercase tracking-wider">
Akses
</button>
<span x-show="String(u.status || '').toLowerCase() !== 'active'" class="text-[10px] bg-red-50 text-red-500 px-1.5 py-0.5 rounded font-bold">
INACTIVE
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</template>
</div>
<template x-if="!loadingUsers && filteredUsers.length === 0">
<div class="text-center py-10 bg-white rounded-xl border border-dashed border-slate-300">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<p class="text-slate-500 text-sm font-medium">Tidak ada pengguna dalam kategori ini</p>
<p class="text-slate-400 text-xs mt-1">Coba pilih kategori lain atau tambahkan pengguna baru</p>
</div>
</template>
</div>
</template>
<!-- Manage Apps -->
<template x-if="!loading && view === 'manage'">
<div x-init="fetchAdminApps()">
<div class="mb-4">
<p class="text-slate-500 text-xs px-2 uppercase font-bold tracking-widest">System Applications</p>
</div>
<template x-if="loadingApps">
<div class="flex flex-col items-center justify-center py-10 bg-white rounded-xl border border-slate-200 shadow-sm">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p class="mt-3 text-xs text-slate-400">Loading system apps...</p>
</div>
</template>
<div x-show="!loadingApps" class="admin-card-list">
<template x-for="app in adminApps" :key="app.appId">
<div class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
<div class="flex-1 min-w-0 mr-4">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-slate-900 truncate" x-text="app.appName"></span>
<span
:class="app.active == 'TRUE' || app.active === true ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
class="px-2 py-0.5 rounded text-[10px] font-bold shrink-0"
x-text="app.active == 'TRUE' || app.active === true ? 'AKTIF' : 'NONAKTIF'"
></span>
</div>
<div class="text-[11px] text-slate-400 font-mono truncate" x-text="app.appId"></div>
<div class="text-[11px] text-primary truncate mt-1" x-text="app.appUrl"></div>
</div>
<div class="flex shrink-0 items-center gap-1">
<button type="button" @click="openAppModal(app)" class="p-2 text-slate-400 bg-slate-50 rounded-lg hover:text-primary" aria-label="Edit aplikasi">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button" @click="confirmDelete(app)" class="p-2 text-slate-400 bg-slate-50 rounded-lg hover:text-red-500" aria-label="Hapus aplikasi">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</template>
<template x-if="adminApps.length === 0">
<div class="text-center py-10 bg-white rounded-xl border border-dashed border-slate-300 italic text-slate-400 text-sm">
Belum ada aplikasi terdaftar
</div>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- FAB -->
<template x-if="isAdmin && (view === 'users' || view === 'manage')">
<button
type="button"
@click="view === 'users' ? openUserModal() : openAppModal()"
class="fab-button bg-primary text-white"
:aria-label="view === 'users' ? 'Tambah pengguna' : 'Tambah aplikasi'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</template>
</div>
<!-- Toast Notification -->
<div
x-show="toast.open"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed bottom-20 left-1/2 -translate-x-1/2 z-[700] w-[90%] max-w-sm"
>
<div :class="{
'bg-green-600': toast.type === 'success',
'bg-amber-500': toast.type === 'warning',
'bg-red-600': toast.type === 'danger'
}" class="px-4 py-3 rounded-2xl shadow-lg text-white flex items-center gap-3">
<div class="bg-white/20 p-1.5 rounded-full">
<svg x-show="toast.type === 'success'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<svg x-show="toast.type === 'danger' || toast.type === 'warning'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex flex-col">
<span class="text-sm font-bold leading-none" x-text="toast.title"></span>
<span class="text-xs opacity-90" x-text="toast.message"></span>
</div>
</div>
</div>
<!-- User Modal -->
<div
x-show="userModalOpen"
x-cloak
class="fixed inset-0 z-[1000] overflow-y-auto"
@keydown.escape.window="userModalOpen = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="userModalOpen"
@click="userModalOpen = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/50 backdrop-blur-sm"
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div
x-show="userModalOpen"
role="dialog"
aria-modal="true"
aria-labelledby="user-modal-title"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="relative z-10 inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle max-w-[400px] w-full mx-auto"
>
<form @submit.prevent="saveUser()">
<div class="bg-white px-6 pt-6 pb-4">
<h3 id="user-modal-title" class="text-lg font-semibold text-slate-900 mb-4" x-text="userForm.isNew ? 'Add New User' : 'Edit User'"></h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Email Address</label>
<input type="email" x-model="userForm.email" :disabled="!userForm.isNew" class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:bg-slate-50" required />
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Full Name</label>
<input type="text" x-model="userForm.name" class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" required />
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Unit / Department</label>
<input type="text" x-model="userForm.unit" class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" placeholder="e.g. Grade 10, Mathematics" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Status</label>
<select x-model="userForm.status" class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Admin Access</label>
<select x-model="userForm.isAdmin" class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none">
<option value="FALSE">No</option>
<option value="TRUE">Yes (Full Admin)</option>
</select>
</div>
</div>
</div>
</div>
<div class="bg-slate-50 px-6 py-4 flex justify-between items-center mt-4">
<div>
<template x-if="!userForm.isNew">
<button
type="button"
@click="confirmDeleteUser(userForm)"
class="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors flex items-center gap-2"
:disabled="saving"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete User
</button>
</template>
</div>
<div class="flex gap-3">
<button type="button" @click="userModalOpen = false" class="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-lg transition-colors">
Cancel
</button>
<button type="submit" :disabled="saving" class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50">
<span x-show="!saving">Save User</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Alert Modal -->
<div
x-show="alertModalOpen"
x-cloak
class="fixed inset-0 z-[2000] overflow-y-auto"
@keydown.escape.window="alertModalOpen = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="alertModalOpen"
@click="alertModalOpen = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/50 backdrop-blur-sm"
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div
x-show="alertModalOpen"
role="dialog"
aria-modal="true"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="relative z-10 inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle max-w-sm w-full mx-auto"
>
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-amber-100 text-amber-600 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900 mb-2" x-text="alertData.title || 'Notification'"></h3>
<p class="text-slate-600 text-sm mb-6" x-text="alertData.message"></p>
<button
@click="alertModalOpen = false"
class="w-full py-2.5 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors"
>
OK
</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div
x-show="confirmModalOpen"
x-cloak
class="fixed inset-0 z-[2000] overflow-y-auto"
@keydown.escape.window="confirmModalOpen = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="confirmModalOpen"
@click="confirmModalOpen = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/50 backdrop-blur-sm"
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div
x-show="confirmModalOpen"
role="dialog"
aria-modal="true"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="relative z-10 inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle max-w-sm w-full mx-auto"
>
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 text-red-600 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900 mb-2" x-text="confirmData.title || 'Are you sure?'"></h3>
<p class="text-slate-600 text-sm mb-6" x-text="confirmData.message"></p>
<div class="flex gap-3">
<button
@click="confirmModalOpen = false"
class="flex-1 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
@click="handleConfirm()"
class="flex-1 py-2.5 bg-red-600 text-white rounded-xl font-semibold hover:bg-red-700 transition-colors"
>
OK
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Access Modal -->
<div
x-show="accessModalOpen"
x-cloak
class="fixed inset-0 z-[1000] overflow-y-auto"
@keydown.escape.window="accessModalOpen = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="accessModalOpen"
@click="accessModalOpen = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/50 backdrop-blur-sm"
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div
x-show="accessModalOpen"
role="dialog"
aria-modal="true"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="relative z-10 inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle max-w-[450px] w-full mx-auto"
>
<div class="bg-white px-6 pt-6 pb-4">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-bold text-slate-900" x-text="'Manage Access'"></h3>
<p class="text-xs text-slate-500" x-text="accessUser ? accessUser.name + ' (' + accessUser.email + ')' : ''"></p>
</div>
<button type="button" @click="accessModalOpen = false" class="text-slate-400 hover:text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<template x-for="app in availableApps" :key="app.appId">
<div class="p-3 border border-slate-100 rounded-xl flex items-center justify-between gap-3 hover:bg-slate-50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<input
type="checkbox"
:checked="isAppAssigned(app.appId)"
@change="toggleAppAccess(app)"
class="w-4 h-4 text-primary rounded focus:ring-primary"
>
<div class="min-w-0">
<p class="text-sm font-semibold text-slate-900 truncate" x-text="app.appName"></p>
<p class="text-[10px] text-slate-400 font-mono" x-text="app.appId"></p>
</div>
</div>
<div x-show="isAppAssigned(app.appId)" class="flex items-center gap-2">
<select
@change="updateAppRole(app.appId, $event.target.value)"
class="text-xs border-slate-200 rounded-md bg-white py-1 px-2 outline-none focus:ring-1 focus:ring-primary"
>
<template x-for="role in ['viewer', 'editor', 'admin']">
<option :value="role" :selected="getAppRole(app.appId) === role" x-text="role.toUpperCase()"></option>
</template>
</select>
</div>
</div>
</template>
</div>
</div>
<div class="bg-slate-50 px-6 py-4 flex justify-end">
<button type="button" @click="accessModalOpen = false" class="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-lg transition-colors">
Done
</button>
</div>
</div>
</div>
</div>
<!-- App Modal -->
<div
x-show="modalOpen"
x-cloak
class="fixed inset-0 z-[1000] overflow-y-auto"
@keydown.escape.window="modalOpen = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="modalOpen"
@click="modalOpen = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/50 backdrop-blur-sm"
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div
x-show="modalOpen"
role="dialog"
aria-modal="true"
aria-labelledby="app-modal-title"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="relative z-10 inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle max-w-[400px] w-full mx-auto"
>
<form @submit.prevent="saveApp()">
<div class="bg-white px-8 pt-8 pb-6">
<h3 id="app-modal-title" class="text-xl font-bold text-slate-900 mb-8" x-text="appForm.isNew ? 'Add New Application' : 'Edit Application'"></h3>
<div class="space-y-6">
<div>
<label class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1">App ID (Unique Slug)</label>
<input type="text" x-model="appForm.appId" :disabled="!appForm.isNew" class="w-full px-3 py-2 bg-slate-100 border-none rounded-lg text-slate-600 outline-none disabled:cursor-not-allowed" required />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1">App Name</label>
<input type="text" x-model="appForm.appName" class="w-full px-0 py-2 bg-transparent border-b border-slate-100 focus:border-primary outline-none transition-colors" required />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1">URL</label>
<input type="url" x-model="appForm.appUrl" class="w-full px-0 py-2 bg-transparent border-b border-slate-100 focus:border-primary outline-none transition-colors" required placeholder="https://..." />
</div>
<div class="grid grid-cols-2 gap-8">
<div>
<label class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1">Sort Order</label>
<input type="number" x-model="appForm.sortOrder" class="w-full px-0 py-2 bg-transparent border-b border-slate-100 focus:border-primary outline-none transition-colors" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1">Status</label>
<select x-model="appForm.active" class="w-full px-0 py-2 bg-transparent border-b border-slate-100 focus:border-primary outline-none transition-colors appearance-none">
<option value="TRUE">Active</option>
<option value="FALSE">Inactive</option>
</select>
</div>
</div>
</div>
</div>
<div class="bg-white px-8 py-6 flex justify-between items-center border-t border-slate-50">
<button type="button" @click="modalOpen = false" class="px-4 py-2 text-sm font-semibold text-slate-500 hover:text-slate-800 transition-colors">
Cancel
</button>
<button type="submit" :disabled="saving" class="px-8 py-3 bg-primary text-white text-sm font-bold rounded-full hover:bg-blue-600 transition-all shadow-md hover:shadow-lg disabled:opacity-50">
<span x-show="!saving">Save App</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<?!= HtmlService.createHtmlOutputFromFile('scripts').getContent(); ?>
</body>
</html>
+507
View File
@@ -0,0 +1,507 @@
<script src="https://cdn.jsdelivr.net/npm/framework7@8/framework7-bundle.min.js"></script>
<script>
function appLauncher() {
return {
// --- Framework7 Instance ---
f7: null,
// --- Core State ---
loading: true,
user: null,
apps: [],
error: null,
isAdmin: false,
authenticated: false,
// --- Toast System ---
toast: {
open: false,
type: 'success', // 'success' | 'warning' | 'danger'
title: '',
message: ''
},
toastTimer: null,
showToast(type, title, message, ms = 2500) {
this.toast.type = type || 'success';
this.toast.title = title || '';
this.toast.message = message || '';
this.toast.open = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
this.toast.open = false;
this.toastTimer = null;
}, ms);
},
// --- Setup & Auth State ---
isSystemEmpty: false,
needsAuth: false,
userEmail: '',
setupName: '',
loginMethod: 'google',
loginLoading: false,
authPhone: '',
authCode: '',
otpSent: false,
// --- Admin View States ---
view: 'home',
loadingProgress: 0,
loadingStage: 0,
loadingMessage: 'Initializing...',
fetchProgress: 0,
layoutMode: 'grid',
adminApps: [],
adminUsers: [],
loadingApps: false,
loadingUsers: false,
userCategory: 'all',
// --- Modal States ---
modalOpen: false, // App Modal
userModalOpen: false, // User Modal
accessModalOpen: false, // Access Modal
confirmModalOpen: false, // Confirm Modal
alertModalOpen: false, // Alert Modal
saving: false,
// --- Form Data ---
appForm: {
isNew: true,
appId: '',
appName: '',
appUrl: '',
sortOrder: 0,
active: 'TRUE'
},
userForm: {
isNew: true,
email: '',
name: '',
unit: '',
status: 'active',
isAdmin: 'FALSE'
},
// --- Modal Content ---
confirmData: { title: '', message: '' },
confirmAction: null,
alertData: { title: '', message: '' },
accessUser: null,
userAccessList: [],
availableApps: [],
// --- Computed ---
get filteredUsers() {
if (this.userCategory === 'all') return this.adminUsers;
if (this.userCategory === 'admin') return this.adminUsers.filter(u => u.isAdmin === 'TRUE' || u.isAdmin === true);
if (this.userCategory === 'active') return this.adminUsers.filter(u => String(u.status || '').toLowerCase() === 'active');
if (this.userCategory === 'inactive') return this.adminUsers.filter(u => String(u.status || '').toLowerCase() !== 'active');
return this.adminUsers;
},
// --- Initialization ---
init() {
this.fetchData();
},
initF7() {
if (this.f7) return;
this.$nextTick(() => {
const appEl = document.getElementById('app');
if (!appEl) return;
// Double check if F7 has already attached to the element to prevent "already initialized" error
if (appEl.classList.contains('framework7-initialized')) return;
try {
this.f7 = new Framework7({
el: '#app',
name: 'SchoolHub',
theme: 'ios',
touch: { tapHold: true }
});
appEl.classList.add('framework7-initialized');
console.log('Framework7 initialized successfully');
} catch (e) {
console.error('F7 Init Error:', e);
}
});
},
// --- Authentication Logic ---
async doGoogleLogin() {
console.log('Attempting Google Login...');
this.loginLoading = true;
google.script.run
.withSuccessHandler((res) => {
console.log('Google Login Response:', res);
this.loginLoading = false;
if (res && res.ok) {
this.completeLogin(res.data);
} else {
const msg = (res && res.message) ? res.message : 'Email tidak terdeteksi.';
this.showToast('danger', 'Gagal', msg);
}
})
.withFailureHandler((err) => {
console.error('Google Login Failure:', err);
this.loginLoading = false;
this.showToast('danger', 'Error', 'Koneksi server gagal');
})
.loginWithGoogle();
},
sendOTP() {
if (!this.authPhone) return this.showToast('warning', 'Peringatan', 'Masukkan nomor WA');
this.loginLoading = true;
google.script.run
.withSuccessHandler((res) => {
this.loginLoading = false;
if (res.ok) {
this.otpSent = true;
this.showToast('success', 'Terkirim', res.message);
} else {
this.showToast('danger', 'Gagal', res.message);
}
})
.withFailureHandler((err) => {
this.loginLoading = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.sendOTP(this.authPhone);
},
verifyOTP() {
if (this.authCode.length < 6) return this.showToast('warning', 'Peringatan', 'Kode OTP harus 6 digit');
this.loginLoading = true;
google.script.run
.withSuccessHandler((res) => {
this.loginLoading = false;
if (res.ok) {
this.completeLogin(res.data);
} else {
this.showToast('danger', 'Gagal', res.message);
}
})
.withFailureHandler((err) => {
this.loginLoading = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.verifyOTP(this.authPhone, this.authCode);
},
completeLogin(data) {
this.authenticated = true;
localStorage.setItem('schoolhub_sid', data.sessionId);
this.fetchData();
this.$nextTick(() => this.initF7());
this.showToast('success', 'Berhasil', 'Selamat datang kembali!');
},
exitApp() {
this.confirmData = {
title: 'Keluar Aplikasi?',
message: 'Apakah Anda yakin ingin keluar dari SchoolHub Launcher?'
};
this.confirmAction = () => {
localStorage.removeItem('schoolhub_sid');
this.authenticated = false;
this.user = null;
this.apps = [];
this.showToast('success', 'Logged Out', 'Anda telah keluar dari aplikasi');
};
this.confirmModalOpen = true;
},
// --- Data Orchestration ---
fetchData() {
const sid = localStorage.getItem('schoolhub_sid');
if (!sid && !this.authenticated) {
this.authenticated = false;
return;
}
this.loading = true;
this.error = null;
this.isSystemEmpty = false;
this.needsAuth = false;
this.user = null;
this.apps = [];
this.isAdmin = false;
this.loadingProgress = 10;
this.loadingStage = 1;
this.loadingMessage = 'Connecting to SchoolHub...';
google.script.run
.withSuccessHandler((res) => {
if (res && res.ok) {
this.loadingProgress = 50;
this.loadingStage = 3;
this.loadingMessage = 'Loading your dashboard...';
if (res.data.isSystemEmpty) {
this.isSystemEmpty = true;
this.userEmail = res.data.userEmail || '';
} else if (res.data.needsAuth) {
this.needsAuth = true;
} else {
this.user = res.data.user || null;
this.apps = res.data.apps || [];
this.isAdmin = res.data.isAdmin || false;
}
} else {
this.error = (res && res.message) ? res.message : 'Access denied.';
}
this.loadingProgress = 100;
this.loadingStage = 6;
this.loading = false;
})
.withFailureHandler((err) => {
this.error = 'A system error occurred. Please try again later.';
this.loading = false;
})
.getInitialData();
},
// --- Admin: App Management ---
fetchAdminApps() {
this.loadingApps = true;
google.script.run
.withSuccessHandler((res) => {
this.loadingApps = false;
if (res && res.ok) this.adminApps = res.data || [];
else this.showToast('danger', 'Error', res.message);
})
.withFailureHandler((err) => {
this.loadingApps = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.getAdminApps();
},
openAppModal(app = null) {
if (app) {
this.appForm = { ...app, isNew: false };
this.appForm.active = (app.active === true || app.active === 'TRUE') ? 'TRUE' : 'FALSE';
} else {
this.appForm = {
isNew: true,
appId: '',
appName: '',
appUrl: '',
sortOrder: this.adminApps.length + 1,
active: 'TRUE'
};
}
this.modalOpen = true;
},
saveApp() {
if (!this.appForm.appId || !this.appForm.appName || !this.appForm.appUrl) {
return this.showToast('warning', 'Peringatan', 'Mohon lengkapi data aplikasi.');
}
this.saving = true;
google.script.run
.withSuccessHandler((res) => {
this.saving = false;
if (res && res.ok) {
this.showToast('success', 'Berhasil', 'Aplikasi berhasil disimpan.');
this.modalOpen = false;
this.fetchAdminApps();
this.fetchData();
} else {
this.showToast('danger', 'Gagal', res.message);
}
})
.withFailureHandler((err) => {
this.saving = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.saveApp(this.appForm);
},
confirmDelete(app) {
this.confirmData = {
title: 'Hapus Aplikasi?',
message: `Apakah Anda yakin ingin menghapus ${app.appName}?`
};
this.confirmAction = () => {
this.saving = true;
google.script.run
.withSuccessHandler((res) => {
this.saving = false;
if (res.ok) {
this.showToast('success', 'Berhasil', 'Aplikasi dihapus.');
this.fetchAdminApps();
this.fetchData();
}
})
.deleteApp(app.appId);
};
this.confirmModalOpen = true;
},
// --- Admin: User Management ---
fetchAdminUsers() {
this.loadingUsers = true;
google.script.run
.withSuccessHandler((res) => {
this.loadingUsers = false;
if (res && res.ok) this.adminUsers = res.data || [];
else this.showToast('danger', 'Error', res.message);
})
.withFailureHandler((err) => {
this.loadingUsers = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.getAdminUsers();
},
openUserModal(user = null) {
if (user) {
this.userForm = { ...user, isNew: false };
this.userForm.isAdmin = (user.isAdmin === true || user.isAdmin === 'TRUE') ? 'TRUE' : 'FALSE';
} else {
this.userForm = { isNew: true, email: '', name: '', unit: '', status: 'active', isAdmin: 'FALSE' };
}
this.userModalOpen = true;
},
saveUser() {
if (!this.userForm.email || !this.userForm.name) {
return this.showToast('warning', 'Peringatan', 'Email dan nama wajib diisi.');
}
this.saving = true;
google.script.run
.withSuccessHandler((res) => {
this.saving = false;
if (res && res.ok) {
this.showToast('success', 'Berhasil', 'User berhasil disimpan.');
this.userModalOpen = false;
this.fetchAdminUsers();
} else {
this.showToast('danger', 'Gagal', res.message);
}
})
.withFailureHandler((err) => {
this.saving = false;
this.showToast('danger', 'Error', 'Koneksi gagal');
})
.saveUser(this.userForm);
},
confirmDeleteUser(user) {
this.confirmData = {
title: 'Hapus Pengguna?',
message: `Apakah Anda yakin ingin menghapus ${user.name}?`
};
this.confirmAction = () => {
this.saving = true;
google.script.run
.withSuccessHandler((res) => {
this.saving = false;
if (res.ok) {
this.userModalOpen = false;
this.showToast('success', 'Berhasil', 'User dihapus.');
this.fetchAdminUsers();
}
})
.deleteUser(user.email);
};
this.confirmModalOpen = true;
},
// --- Admin: Access Management ---
async openAccessModal(user) {
this.accessUser = user;
this.accessModalOpen = true;
google.script.run
.withSuccessHandler((res) => {
if (res && res.ok) {
this.availableApps = res.data.availableApps;
this.userAccessList = res.data.userAccess;
}
})
.getAccessForUser(user.email);
},
isAppAssigned(appId) {
return this.userAccessList.some(a => a.appId === appId);
},
getAppRole(appId) {
const access = this.userAccessList.find(a => a.appId === appId);
return access ? (access.roles || 'viewer').split(',')[0] : 'viewer';
},
async toggleAppAccess(app) {
const isAssigned = this.isAppAssigned(app.appId);
if (isAssigned) {
google.script.run
.withSuccessHandler((res) => {
if (res.ok) {
this.userAccessList = this.userAccessList.filter(a => a.appId !== app.appId);
this.showToast('success', 'Akses Dicabut', `Akses ${app.appName} dihapus`);
}
})
.deleteAccess(this.accessUser.email, app.appId);
} else {
const data = { email: this.accessUser.email, appId: app.appId, roles: 'viewer', active: 'TRUE' };
google.script.run
.withSuccessHandler((res) => {
if (res.ok) {
this.userAccessList.push({ appId: app.appId, roles: 'viewer', active: 'TRUE' });
this.showToast('success', 'Akses Diberikan', `Akses ${app.appName} diberikan`);
}
})
.saveAccess(data);
}
},
async updateAppRole(appId, newRole) {
const data = { email: this.accessUser.email, appId: appId, roles: newRole, active: 'TRUE' };
google.script.run
.withSuccessHandler((res) => {
if (res.ok) {
const idx = this.userAccessList.findIndex(a => a.appId === appId);
if (idx !== -1) this.userAccessList[idx].roles = newRole;
this.showToast('success', 'Role Updated', 'Role berhasil diperbarui');
}
})
.saveAccess(data);
},
// --- System Setup ---
registerFirstAdmin() {
if (!this.setupName) return alert('Nama admin wajib diisi.');
this.saving = true;
google.script.run
.withSuccessHandler((res) => {
this.saving = false;
if (res && res.ok) this.fetchData();
else alert(res.message);
})
.registerFirstAdmin(this.setupName);
},
// --- General Helpers ---
handleConfirm() {
if (this.confirmAction) {
this.confirmAction();
this.confirmAction = null;
}
this.confirmModalOpen = false;
},
openApp(url) {
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
};
}
</script>
+222
View File
@@ -0,0 +1,222 @@
<style>
:root {
--primary: #2481cc;
--text: #111827;
--tg-bg: #f0f2f5;
}
.text-primary { color: var(--primary); }
.bg-primary { background-color: var(--primary); }
.border-primary { border-color: var(--primary); }
[x-cloak] { display: none !important; }
/* Framework7 & Mobile Adjustments */
/* Mobile App Wrapper */
body {
background-color: #f1f5f9;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
#app-wrapper {
width: 100%;
max-width: 450px; /* Lebar maksimal layaknya ponsel */
height: 100vh;
max-height: 900px; /* Tinggi maksimal pada desktop */
background: var(--tg-bg);
position: relative;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
}
#app {
height: 100%;
width: 100%;
}
@media (max-width: 450px) {
#app {
max-height: none;
box-shadow: none;
}
}
.navbar {
--f7-navbar-height: 70px;
height: var(--f7-navbar-height);
position: absolute;
top: 0;
width: 100%;
z-index: 500;
background: transparent !important;
}
/* Floating Bottom Nav Pill */
.floating-nav {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 380px;
height: 64px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
border-radius: 32px;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 8px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
z-index: 600;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.nav-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 20px;
color: #64748b;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-item.active {
background-color: var(--primary);
color: white;
box-shadow: 0 4px 12px rgba(36, 129, 204, 0.4);
transform: translateY(-2px);
}
/* Padding Top disesuaikan agar konten tidak tertutup navbar */
.page-content {
padding-top: 80px !important;
padding-bottom: 110px !important;
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.tab-link-active { font-weight: 600; }
/* Custom Admin List for Mobile */
.admin-card-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Smooth Transitions */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
/* Absolute FAB inside App Wrapper */
.fab-button {
position: absolute;
bottom: 100px; /* Di atas floating nav */
right: 24px;
width: 56px;
height: 56px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 16px -4px rgba(36, 129, 204, 0.5);
z-index: 550;
cursor: pointer;
transition: all 0.2s;
}
.fab-button:active {
transform: scale(0.95);
}
/* User List Items */
.user-item {
border-bottom: 0.5px solid #eee;
}
/* Progress Ring Styles */
.progress-ring-container {
position: relative;
width: 120px;
height: 120px;
}
.progress-ring {
transform: rotate(-90deg);
width: 120px;
height: 120px;
}
.progress-ring-circle {
stroke-dasharray: 339.292;
stroke-dashoffset: 339.292;
transition: stroke-dashoffset 0.5s ease-in-out;
}
.progress-ring-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
/* User Category Bar */
.category-bar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.05);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.category-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 12px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid transparent;
}
.category-pill.active {
background: var(--primary);
color: white;
box-shadow: 0 4px 10px rgba(36, 129, 204, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.category-count {
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
}
.category-pill.active .category-count {
background: rgba(255, 255, 255, 0.2);
}
</style>
+14
View File
@@ -0,0 +1,14 @@
{
"timeZone": "Asia/Bangkok",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
},
"oauthScopes": [
"https://www.googleapis.com/auth/script.projects.readonly",
"https://www.googleapis.com/auth/script.external_request"
]
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 📘 SCHOOLHUB LAUNCHER - PROJECT DOCUMENTATION
* =============================================================================
*
* 1. OVERVIEW
* -----------
* SchoolHub Launcher is a centralized portal designed for school environments.
* It allows administrators to manage a list of internal applications and
* control which users have access to which apps based on their roles.
*
* CORE STACK:
* - Backend: Google Apps Script (GAS)
* - Database: Google Sheets
* - Frontend: Alpine.js, Tailwind CSS, Framework7 (Mobile UI)
* - Auth: Google Account & WhatsApp OTP (via GoWhatsApp API)
*
* 2. QUICK START SETUP
* --------------------
* To get the application running for the first time:
*
* STEP 1: Spreadsheet Connection
* - Ensure the SPREADSHEET_ID in Code.gs matches your target Google Sheet ID.
*
* STEP 2: Database Initialization
* - In the Script Editor, select the function `setupDatabase` from the dropdown.
* - Click "Run". This creates all necessary tabs (Users, Access, Apps, etc.)
* and sets up the header rows.
*
* STEP 3: Deployment
* - Click "Deploy" -> "New Deployment".
* - Select "Web App".
* - Execute as: "Me".
* - Who has access: "Anyone" (The app handles its own internal authentication).
*
* STEP 4: First Admin
* - Open the Web App URL.
* - Since the system is empty, it will prompt you to register as the First Administrator.
*
* 3. DATABASE SCHEMA (Google Sheets)
* ----------------------------------
* The system relies on these specific tabs:
*
* | Tab Name | Purpose | Key Columns |
* |-------------|----------------------------------------------|-----------------------------------|
* | Users | Registered users and global admin status | email, name, status, isAdmin |
* | Access | App-specific permissions (RBAC) | email, appId, roles, active |
* | Apps | Registry of available applications | appId, appName, appUrl, sortOrder |
* | AuditLog | History of access and admin actions | timestamp, email, action, status |
* | OTP | Temporary storage for WhatsApp verification | phone, code, expiresAt |
* | Sessions | Active session tokens | sessionId, email, expiresAt |
* | SystemDocs | Global system documentation/settings | docType, content, updatedAt |
*
* 4. CONFIGURATION
* -----------------
* To modify system behavior, look for these constants in Code.gs:
*
* - SPREADSHEET_ID: The ID of the Google Sheet used as the DB.
* - GO_WHATSAPP_API_KEY: Your API key for the GoWhatsApp service.
* - GO_WHATSAPP_URL: The endpoint for sending WhatsApp messages.
*
* 5. PERMISSION LOGIC (RBAC)
* --------------------------
* - Global Admin: If a user has `isAdmin = TRUE` in the 'Users' sheet, they
* can see ALL apps and access the Admin Dashboard.
* - App Access: For non-admins, the system checks the 'Access' sheet.
* A user must have an 'active' row matching the `appId` to see that app.
* - Roles: Roles (viewer, editor, admin) are stored in the 'Access' sheet
* and can be passed to the target app to handle internal permissions.
*
* 6. MAINTENANCE & TROUBLESHOOTING
* --------------------------------
* - Cache Issues: The system uses CacheService for performance. If you
* manually edit the Sheet and don't see changes, wait 5 minutes or
* restart the browser.
* - OTP Failures: Ensure the phone number in the 'Users' sheet matches
* the number used during login (including country code).
* - Deployment: Whenever you change Code.gs, remember to create a
* "New Deployment" or update the "Active" deployment to see changes.
*
* 7. CHANGELOG
* -------------
* [2023-10-27] - v1.1.0
* - Fitur: Tambah sistem OTP WhatsApp.
* - Perbaikan: Fix bug pada filter kategori user.
* - Perubahan: Update UI Navbar menjadi lebih minimalis.
*
* [2023-10-20] - v1.0.0
* - Initial Release: Dashboard, User Management, App Launcher.
*
*
* =============================================================================
*/
+5
View File
@@ -0,0 +1,5 @@
{
"featureCode": "manual",
"syncDate": "2026-04-24T23:45:06.978Z",
"filesSynced": 12
}
+106
View File
@@ -0,0 +1,106 @@
/**
* GiteaClient - Low-level API wrapper for Gitea Git Hosting
*/
/**
* Helper to ensure the base URL always points to the Gitea API root (/api/v1)
* @return {string} Normalized API base URL
*/
function getGiteaApiBaseUrl_() {
const config = getGiteaConfig();
let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
// Ensure the URL ends with /api/v1
if (!baseUrl.endsWith('/api/v1')) {
baseUrl += '/api/v1';
}
return baseUrl;
}
function giteaRequest_(method, path, payload = null) {
const apiBaseUrl = getGiteaApiBaseUrl_();
const config = getGiteaConfig();
// Ensure path starts with a single slash
const normalizedPath = path.startsWith('/') ? path : '/' + path;
const url = `${apiBaseUrl}${normalizedPath}`;
console.log(`Gitea Request: [${method}] ${url}`);
const options = {
method: method,
headers: {
'Authorization': `token ${config.token}`,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
};
if (payload) {
options.payload = JSON.stringify(payload);
}
try {
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
const rawContent = response.getContentText();
let json;
try {
json = JSON.parse(rawContent);
} catch (e) {
json = null; // Not a JSON response
}
if (code >= 200 && code < 300) {
return { ok: true, data: json || rawContent };
}
// Return structured error with raw body if JSON parsing failed
return {
ok: false,
message: json && json.message ? json.message : `Gitea API Error ${code}`,
code: code,
rawBody: rawContent
};
} catch (e) {
return { ok: false, message: `Gitea Connection Error: ${e.message}` };
}
}
function getGiteaRepo() {
const config = getGiteaConfig();
// Path is relative to /api/v1
return giteaRequest_('GET', `/repos/${config.owner}/${config.repo}`);
}
function createGiteaIssue(title, body) {
const config = getGiteaConfig();
return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/issues`, {
title: title,
body: body
});
}
function createOrUpdateRepoFile(path, content, message, branch) {
const config = getGiteaConfig();
const targetBranch = branch || config.branch;
// Gitea requires SHA for updates. This is a simplified boilerplate.
return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/contents/${path}`, {
content: content,
message: message,
branch: targetBranch
});
}
function createReleaseTag(tagName, name, body, target) {
const config = getGiteaConfig();
return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/releases`, {
tag_name: tagName,
name: name,
body: body,
target_commitish: target || config.branch
});
}
+402
View File
@@ -0,0 +1,402 @@
function getSyncConfig() {
const p = PropertiesService.getScriptProperties().getProperties();
return {
baseUrl: p.GITEA_BASE_URL || '',
owner: p.GITEA_OWNER || '',
repo: p.GITEA_REPO || '',
token: p.GITEA_TOKEN || '',
defaultBranch: p.GITEA_DEFAULT_BRANCH || 'main',
scriptId: p.SCRIPT_ID || ScriptApp.getScriptId()
};
}
function formatSyncTimestamp() {
return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd-HHmm");
}
function encodeBase64Utf8(text) {
return Utilities.base64Encode(text || '', Utilities.Charset.UTF_8);
}
function buildSyncBranchName(featureCode) {
var safeFeature = String(featureCode || "manual")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return "sync/" + (safeFeature || "manual") + "-" + formatSyncTimestamp();
}
function mapAppsScriptFileToRepoPath(file) {
if (!file || !file.name || !file.type) return null;
if (file.name === 'appsscript' && file.type === 'JSON') {
return 'appsscript.json';
}
const appFiles = ['Code', 'Index', 'styles', 'scripts'];
const docsFiles = ['Readme'];
let prefix = 'workflow/';
if (appFiles.includes(file.name)) {
prefix = 'app/';
} else if (docsFiles.includes(file.name)) {
prefix = 'docs/';
}
if (file.type === 'SERVER_JS') {
return prefix + file.name + '.gs';
}
if (file.type === 'HTML') {
return prefix + file.name + '.html';
}
Logger.log('Unknown file type for mapping: ' + file.name + ' (' + file.type + ')');
return null;
}
function prepareGitSyncPayload(featureCode, appScriptFiles) {
const config = getSyncConfig();
const syncBranch = buildSyncBranchName(featureCode);
const mappedFiles = [];
if (appScriptFiles && Array.isArray(appScriptFiles)) {
appScriptFiles.forEach(function(file) {
const repoPath = mapAppsScriptFileToRepoPath(file);
if (repoPath) {
mappedFiles.push({
repoPath: repoPath,
content: file.source || '',
originalName: file.name,
type: file.type
});
}
});
}
return {
baseBranch: config.defaultBranch,
syncBranch: syncBranch,
files: mappedFiles
};
}
function getGiteaSyncApiUrl(path) {
const config = getSyncConfig();
let baseUrl = config.baseUrl.replace(/\/$/, '');
if (!baseUrl.endsWith('/api/v1')) {
baseUrl += '/api/v1';
}
const normalizedPath = path.startsWith('/') ? path : '/' + path;
return baseUrl + normalizedPath;
}
function getGiteaSyncRequestOptions(method, payload) {
const config = getSyncConfig();
const options = {
method: method,
headers: {
'Authorization': 'token ' + config.token
},
contentType: 'application/json',
muteHttpExceptions: true
};
if (payload) {
options.payload = JSON.stringify(payload);
}
return options;
}
function giteaSyncRequest(method, path, payload) {
const url = getGiteaSyncApiUrl(path);
const options = getGiteaSyncRequestOptions(method, payload);
try {
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
const rawContent = response.getContentText();
let json = null;
try {
json = JSON.parse(rawContent);
} catch (e) {}
if (code >= 200 && code < 300) {
return { ok: true, data: json || rawContent, code: code };
}
return {
ok: false,
message: json && json.message ? json.message : 'API Error ' + code,
code: code,
rawBody: rawContent
};
} catch (e) {
return { ok: false, message: 'Connection Error: ' + e.message, code: 500 };
}
}
function createGiteaSyncBranch(baseBranch, newBranch) {
const config = getSyncConfig();
const path = '/repos/' + config.owner + '/' + config.repo + '/branches';
const payload = {
new_branch_name: newBranch,
old_ref_name: baseBranch || config.defaultBranch
};
return giteaSyncRequest('POST', path, payload);
}
function getGiteaFileMetadata(repoPath, branch) {
const config = getSyncConfig();
const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath + '?ref=' + encodeURIComponent(branch);
return giteaSyncRequest('GET', path, null);
}
function upsertGiteaSyncFile(repoPath, content, branch, message) {
const config = getSyncConfig();
const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath;
const metaRes = getGiteaFileMetadata(repoPath, branch);
let sha = null;
if (metaRes.ok && metaRes.data && metaRes.data.sha) {
sha = metaRes.data.sha;
} else if (!metaRes.ok && metaRes.code !== 404) {
return metaRes;
}
const payload = {
branch: branch,
content: encodeBase64Utf8(content),
message: message || 'Update ' + repoPath
};
if (sha) {
payload.sha = sha;
}
return giteaSyncRequest(sha ? 'PUT' : 'POST', path, payload);
}
function getAppsScriptProjectContent() {
const config = getSyncConfig();
const currentScriptId = ScriptApp.getScriptId();
const targetScriptId = config.scriptId || currentScriptId;
console.log("--- Diagnostic: getAppsScriptProjectContent ---");
console.log("Configured Script ID : " + (config.scriptId || "(empty)"));
console.log("Current Script ID : " + currentScriptId);
if (config.scriptId && config.scriptId !== currentScriptId) {
console.warn("MISMATCH: Configured SCRIPT_ID is different from current ScriptApp.getScriptId(). This may cause a 403 error.");
} else {
console.log("MATCH: Script IDs match (or using default fallback).");
}
const url = "https://script.googleapis.com/v1/projects/" + targetScriptId + "/content";
const token = ScriptApp.getOAuthToken();
const options = {
method: "get",
headers: {
Authorization: "Bearer " + token
},
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
const rawContent = response.getContentText();
if (code === 200) {
const data = JSON.parse(rawContent);
const files = data.files || [];
console.log("Response Code : " + code);
console.log("Files Retrieved : " + files.length);
console.log("File Names : " + files.map(function(f){ return f.name; }).join(", "));
console.log("-----------------------------------------------");
return { ok: true, data: data };
}
console.log("Response Code : " + code);
console.log("Raw Response Body : " + rawContent);
console.log("-----------------------------------------------");
let errorMsg = "Failed to fetch project content. Code: " + code;
if (code === 403) {
errorMsg = "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.";
}
return { ok: false, message: errorMsg, code: code, rawBody: rawContent };
}
function normalizeAppsScriptFiles(files) {
return files.map(function(file) {
return {
name: file.name,
type: file.type,
source: file.source || ''
};
});
}
function exportAppsScriptProject() {
const res = getAppsScriptProjectContent();
if (!res.ok) return res;
return { ok: true, files: normalizeAppsScriptFiles(res.data.files || []) };
}
function buildSyncManifest(featureCode, mappedFiles) {
const manifest = {
featureCode: featureCode,
syncDate: new Date().toISOString(),
filesSynced: mappedFiles.length
};
return JSON.stringify(manifest, null, 2);
}
function checkGiteaPullRequestExists(baseBranch, headBranch) {
const config = getSyncConfig();
const path = '/repos/' + config.owner + '/' + config.repo + '/pulls?state=open';
const res = giteaSyncRequest('GET', path, null);
if (res.ok && res.data && Array.isArray(res.data)) {
const existing = res.data.find(function(pr) {
return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch;
});
if (existing) {
return { ok: true, existed: true, data: existing };
}
return { ok: true, existed: false };
}
return { ok: false, existed: false, message: res.message || 'Failed to check existing PRs' };
}
function createGiteaPullRequest(title, body, baseBranch, headBranch) {
const config = getSyncConfig();
const checkRes = checkGiteaPullRequestExists(baseBranch, headBranch);
if (checkRes.existed) {
return { ok: true, existed: true, data: checkRes.data, message: 'Pull request already exists' };
}
if (!checkRes.ok) {
return checkRes;
}
const path = '/repos/' + config.owner + '/' + config.repo + '/pulls';
const payload = {
title: title,
body: body,
base: baseBranch,
head: headBranch
};
const res = giteaSyncRequest('POST', path, payload);
if (res.ok) {
res.existed = false;
}
return res;
}
function ensureGiteaBranch(baseBranch, newBranch) {
const config = getSyncConfig();
const checkRes = giteaSyncRequest('GET', '/repos/' + config.owner + '/' + config.repo + '/branches/' + newBranch, null);
if (checkRes.ok) {
return { ok: true, existed: true };
}
const createRes = createGiteaSyncBranch(baseBranch, newBranch);
if (!createRes.ok && createRes.message && createRes.message.toLowerCase().indexOf('empty') !== -1) {
console.log('Git Repository is empty. Attempting to bootstrap with initial commit...');
const bootstrapRes = giteaSyncRequest('POST', '/repos/' + config.owner + '/' + config.repo + '/contents/README.md', {
branch: baseBranch || config.defaultBranch,
content: encodeBase64Utf8('# SchoolHub Sync\nRepository initialized automatically.'),
message: 'Initial commit'
});
if (bootstrapRes.ok) {
console.log('Bootstrap successful. Retrying branch creation...');
return createGiteaSyncBranch(baseBranch, newBranch);
}
return { ok: false, message: 'Git Repository is empty and bootstrap failed: ' + bootstrapRes.message + '. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).' };
}
return createRes;
}
function syncAppsScriptToGiteaBranch(featureCode) {
console.log("Starting Gitea sync for feature: " + featureCode);
const projectRes = exportAppsScriptProject();
if (!projectRes.ok) return projectRes;
const payload = prepareGitSyncPayload(featureCode, projectRes.files);
if (!payload.files || payload.files.length === 0) {
return { ok: false, message: 'No valid files to sync.' };
}
const branchRes = ensureGiteaBranch(payload.baseBranch, payload.syncBranch);
if (!branchRes.ok) return { ok: false, message: 'Branch creation failed: ' + branchRes.message };
console.log("Branch ready: " + payload.syncBranch + " (Existed: " + !!branchRes.existed + ")");
const results = { created: 0, updated: 0, errors: 0, details: [] };
payload.files.forEach(function(file) {
const res = upsertGiteaSyncFile(file.repoPath, file.content, payload.syncBranch, 'Sync ' + file.repoPath);
results.details.push({ file: file.repoPath, ok: res.ok, message: res.message });
if (res.ok) {
if (res.code === 201) results.created++;
else results.updated++;
} else {
results.errors++;
}
});
const manifestContent = buildSyncManifest(featureCode, payload.files);
const manifestRes = upsertGiteaSyncFile('sync-manifest.json', manifestContent, payload.syncBranch, 'Update sync manifest');
if (manifestRes.ok) {
if (manifestRes.code === 201) results.created++;
else results.updated++;
} else {
results.errors++;
}
const prTitle = 'Sync Feature: ' + featureCode;
const prBody = 'Automated sync from Google Apps Script for feature: ' + featureCode + '.\n\nFiles synced:\n' + payload.files.map(function(f) { return '- ' + f.repoPath; }).join('\n');
const prRes = createGiteaPullRequest(prTitle, prBody, payload.baseBranch, payload.syncBranch);
let prInfo = null;
if (prRes.ok) {
prInfo = {
url: prRes.data.html_url || '',
existed: prRes.existed,
number: prRes.data.number
};
}
console.log("Sync Summary: " + results.created + " created, " + results.updated + " updated, " + results.errors + " errors.");
if (prInfo) {
console.log("Pull Request: " + prInfo.url + (prInfo.existed ? " (Existed)" : " (Created)"));
} else {
console.log("Pull Request Failed: " + prRes.message);
}
return { ok: true, branch: payload.syncBranch, summary: results, pullRequest: prInfo };
}
function runManualSyncToGitea() {
const res = syncAppsScriptToGiteaBranch('manual');
if (res.ok) {
console.log('=== Sync Summary ===');
console.log('Branch Sync: ' + res.branch);
console.log('Files: ' + res.summary.created + ' created, ' + res.summary.updated + ' updated, ' + res.summary.errors + ' errors.');
console.log('Catatan: File baru akan menggunakan path app/, docs/, dan workflow/. File lama dengan folder src/ mungkin masih tertinggal di repositori (Gitea) dan harus dihapus secara manual.');
if (res.pullRequest) {
console.log('Pull Request Status: ' + (res.pullRequest.existed ? 'Existing' : 'Created'));
console.log('Pull Request URL: ' + res.pullRequest.url);
} else {
console.log('Pull Request Status: Not available');
}
} else {
console.error('Sync failed: ' + res.message);
}
return res;
}
+226
View File
@@ -0,0 +1,226 @@
/**
* VikunjaClient - Low-level API wrapper for Vikunja Task Management
*/
/**
* Helper to ensure the base URL always points to the Vikunja API root (/api/v1)
* @return {string} Normalized API base URL
*/
function getVikunjaApiBaseUrl_() {
const config = getVikunjaConfig();
let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
if (!baseUrl.endsWith('/api/v1')) {
baseUrl += '/api/v1';
}
return baseUrl;
}
function vikunjaRequest_(method, path, payload = null) {
const apiBaseUrl = getVikunjaApiBaseUrl_();
const normalizedPath = path.startsWith('/') ? path : '/' + path;
const url = `${apiBaseUrl}${normalizedPath}`;
console.log(`Vikunja Request: [${method}] ${url}`);
const config = getVikunjaConfig();
if (!config.token) {
throw new Error('Vikunja Token is missing from configuration.');
}
const options = {
method: method,
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
};
console.log('Vikunja Auth Header Present:', !!options.headers.Authorization);
if (payload) {
options.payload = JSON.stringify(payload);
}
try {
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
const rawBody = response.getContentText();
let json = null;
if (rawBody) {
try {
json = JSON.parse(rawBody);
} catch (e) {
console.warn(`Non-JSON response received from Vikunja: ${rawBody.substring(0, 200)}...`);
}
}
if (code >= 200 && code < 300) {
return {
ok: true,
code: code,
data: json,
rawBody: rawBody,
message: 'Request successful'
};
}
return {
ok: false,
code: code,
data: json,
rawBody: rawBody,
message: json && json.message ? json.message : `Vikunja API Error ${code}`
};
} catch (e) {
return {
ok: false,
code: 500,
data: null,
rawBody: null,
message: `Vikunja Connection Error: ${e.message}`
};
}
}
function getVikunjaTask(taskId) {
return vikunjaRequest_('GET', `/tasks/${taskId}`);
}
function createVikunjaComment(taskId, commentText) {
const payload = { comment: commentText };
const path = `/tasks/${taskId}/comments`;
console.log(`Vikunja Comment Create Attempt: [PUT] ${path} | Payload: ${JSON.stringify(payload)}`);
const res = vikunjaRequest_('PUT', path, payload);
console.log(`Vikunja Comment Response: Code ${res.code} | Body: ${res.rawBody}`);
return res;
}
function updateVikunjaTask(taskId, data) {
console.log(`Vikunja Task Update Attempt: [POST] /tasks/${taskId} | Payload: ${JSON.stringify(data)}`);
const res = vikunjaRequest_('POST', `/tasks/${taskId}`, data);
console.log(`Vikunja Task Update Response: Code ${res.code} | Body: ${res.rawBody}`);
return res;
}
function setVikunjaTaskDone(taskId, isDone) {
return updateVikunjaTask(taskId, { done: isDone });
}
function createVikunjaTask(taskData) {
const projectId = taskData.projectId || taskData.project_id;
if (!projectId) {
return { ok: false, message: 'projectId is required to create a task in Vikunja.' };
}
// Construct full payload including priority and description
const payload = {
title: taskData.title,
description: taskData.description || '',
priority: typeof taskData.priority === 'number' ? taskData.priority : 0
};
const endpoint = `/projects/${projectId}/tasks`;
console.log(`Vikunja Create Attempt: [PUT] ${endpoint} | Payload: ${JSON.stringify(payload)}`);
// Using PUT as per instance-specific evidence (POST returns 405)
const res = vikunjaRequest_('PUT', endpoint, payload);
console.log(`Vikunja Response: Code ${res.code} | Body: ${res.rawBody}`);
if (res.ok && res.data && res.data.id) {
return {
ok: true,
code: res.code,
data: res.data,
rawBody: res.rawBody,
message: 'Task created successfully'
};
}
return {
ok: false,
code: res.code || 500,
data: res.data || null,
rawBody: res.rawBody || '',
message: `Task creation failed: ${res.message || 'Unknown error'}. Raw body: ${res.rawBody || 'No body'}`
};
}
function searchVikunjaTasks(query) {
// API v1 /tasks returns list of tasks.
// We fetch and filter client-side to avoid complex filter syntax errors.
const res = vikunjaRequest_('GET', '/tasks');
if (!res.ok) return res;
const filtered = (res.data || []).filter(t =>
t.title && t.title.toLowerCase().includes(query.toLowerCase())
);
return { ok: true, data: filtered };
}
function getLabelIdByName_(labelName) {
const res = vikunjaRequest_('GET', '/labels');
if (!res.ok) return null;
// Vikunja labels typically use 'title' for the display name
const label = (res.data || []).find(l =>
(l.title && l.title.toLowerCase() === labelName.toLowerCase()) ||
(l.name && l.name.toLowerCase() === labelName.toLowerCase())
);
return label ? label.id : null;
}
function createVikunjaLabel(labelName) {
// Using PUT as per instance evidence for label creation
const payload = { title: labelName };
console.log(`Vikunja Label Create Attempt: [PUT] /labels | Payload: ${JSON.stringify(payload)}`);
const res = vikunjaRequest_('PUT', '/labels', payload);
if (res.ok && res.data && res.data.id) {
return { ok: true, id: res.data.id, code: res.code };
}
console.error(`Vikunja Label Create Failed: Code ${res.code} | Body: ${res.rawBody}`);
return { ok: false, message: res.message || 'Failed to create label', code: res.code };
}
function addVikunjaLabel(taskId, labelName) {
let labelId = getLabelIdByName_(labelName);
// Auto-create label if it doesn't exist
if (!labelId) {
const createRes = createVikunjaLabel(labelName);
if (!createRes.ok) {
return {
ok: false,
message: `Label resolution failed: ${createRes.message}`,
code: createRes.code
};
}
labelId = createRes.id;
}
// Attach label to task using PUT as per instance evidence
const payload = { label_id: labelId };
console.log(`Vikunja Label Attach Attempt: [PUT] /tasks/${taskId}/labels | Payload: ${JSON.stringify(payload)}`);
const res = vikunjaRequest_('PUT', `/tasks/${taskId}/labels`, payload);
if (!res.ok) {
console.error(`Vikunja Label Attach Failed: Code ${res.code} | Body: ${res.rawBody}`);
}
return res;
}
function deleteVikunjaTask(taskId) {
return vikunjaRequest_('DELETE', `/tasks/${taskId}`);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* WorkflowConfig - Centralized configuration management
* Uses PropertiesService to keep secrets out of the codebase.
*/
function getWorkflowConfig() {
const props = PropertiesService.getScriptProperties().getProperties();
return props;
}
function getRequiredProperty_(key) {
const value = PropertiesService.getScriptProperties().getProperty(key);
if (!value) {
throw new Error(`Missing required configuration property: ${key}. Please set it in Project Settings > Script Properties.`);
}
return value;
}
function getOptionalProperty_(key, fallback) {
const value = PropertiesService.getScriptProperties().getProperty(key);
return value !== null ? value : fallback;
}
function getVikunjaConfig() {
return {
baseUrl: getRequiredProperty_('VIKUNJA_BASE_URL'),
token: getRequiredProperty_('VIKUNJA_TOKEN'),
projectId: getOptionalProperty_('VIKUNJA_DEFAULT_PROJECT_ID', null)
};
}
function getTestProjectConfig() {
return {
testProjectId: getOptionalProperty_('VIKUNJA_TEST_PROJECT_ID', null)
};
}
function getGiteaConfig() {
return {
baseUrl: getRequiredProperty_('GITEA_BASE_URL'),
token: getRequiredProperty_('GITEA_TOKEN'),
owner: getRequiredProperty_('GITEA_OWNER'),
repo: getRequiredProperty_('GITEA_REPO'),
branch: getOptionalProperty_('GITEA_DEFAULT_BRANCH', 'main')
};
}
+178
View File
@@ -0,0 +1,178 @@
/**
* WorkflowHooks - High-level orchestrator for business workflows.
* Core app files should call these functions instead of Clients directly.
*/
function workflowStartFeature(featureCode, taskId) {
const note = `🚀 Feature [${featureCode}] has been started in SchoolHub.`;
const res = createVikunjaComment(taskId, note);
return res.ok ? { ok: true } : { ok: false, message: res.message };
}
function workflowMarkInternalTest(featureCode, taskId, note) {
const fullNote = `🧪 Internal Testing for [${featureCode}]: ${note}`;
const res = createVikunjaComment(taskId, fullNote);
return res.ok ? { ok: true } : { ok: false, message: res.message };
}
function workflowMarkDeploySuccess(featureCode, taskId, deployInfo) {
// 1. Add deployment confirmation comment
const comment = `✅ Deployed to Production: ${deployInfo || 'No additional info'}`;
createVikunjaComment(taskId, comment);
// 2. Automatically mark task as done using the workflow helper
// We pass a minimal meta object to resolve the task
markWorkflowTaskDone_({ featureCode, title: 'Deployment' }, true);
return { ok: true };
}
/**
* Private helper to mark a workflow task as done or reopened.
* Resolves the task ID via ensureWorkflowTask to ensure consistency.
*/
function markWorkflowTaskDone_(featureMeta, isDone) {
try {
const ensureRes = ensureWorkflowTask(featureMeta);
if (!ensureRes.ok || !ensureRes.taskId) {
console.warn(`Workflow Done Status Warning: Could not resolve task for ${featureMeta.featureCode}`);
return;
}
const taskId = ensureRes.taskId;
const patch = { done: isDone };
const res = updateVikunjaTask(taskId, patch);
if (res.ok) {
console.log(`Workflow Done Status: Task ${taskId} marked as ${isDone ? 'DONE' : 'OPEN'}`);
} else {
console.warn(`Workflow Done Status Failure: ${res.message}`);
}
} catch (e) {
console.error(`Workflow Done Status Error: ${e.message}`);
}
}
function workflowCreateRelease(featureCode, taskId, releaseInfo) {
const { tagName, name, body } = releaseInfo;
// 1. Create Release in Gitea
const gRes = createReleaseTag(tagName, name, body, null);
if (!gRes.ok) return { ok: false, message: `Gitea Release Failed: ${gRes.message}` };
// 2. Link Gitea release back to Vikunja task
const config = getGiteaConfig();
const releaseUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/releases/tags/${tagName}`;
const vRes = createVikunjaComment(taskId, `📦 Release created: ${releaseUrl}`);
return vRes.ok ? { ok: true } : { ok: false, message: vRes.message };
}
/**
* Ensures a Vikunja task exists for a specific feature/bug.
* If it exists, returns it. If not, creates it with calculated priority and labels.
*
* @param {Object} featureMeta - { featureCode, title, type, module, impactArea, severity, requestedBy, source }
*/
function ensureWorkflowTask(featureMeta) {
try {
const { featureCode, title } = featureMeta;
if (!featureCode || !title) throw new Error('featureCode and title are required for task assurance.');
// 1. Try to find existing task
const existingTask = findExistingWorkflowTask_(featureMeta);
if (existingTask) {
return {
ok: true,
taskId: existingTask.id,
state: 'EXISTING',
priority: existingTask.priority,
labels: existingTask.labels || []
};
}
// 2. Derive Priority and Labels
const priority = deriveWorkflowPriority_(featureMeta);
const labels = deriveWorkflowLabels_(featureMeta);
// 3. Create Task
const config = getVikunjaConfig();
const taskPayload = {
title: `[${featureCode}] ${title}`,
description: `Source: ${featureMeta.source || 'System'}\nRequested by: ${featureMeta.requestedBy || 'Unknown'}\nModule: ${featureMeta.module || 'General'}`,
projectId: config.projectId,
priority: priority
};
const createRes = createVikunjaTask(taskPayload);
if (!createRes.ok) throw new Error(`Task creation failed: ${createRes.message}`);
const taskId = createRes.data.id;
// 4. Attach Labels
attachLabelsToTask_(taskId, labels);
return {
ok: true,
taskId: taskId,
state: 'CREATED',
priority: priority,
labels: labels
};
} catch (e) {
console.error(`Workflow Task Assurance Error: ${e.message}`);
return { ok: false, message: e.message };
}
}
function findExistingWorkflowTask_(featureMeta) {
const searchRes = searchVikunjaTasks(featureMeta.featureCode);
if (searchRes.ok && Array.isArray(searchRes.data)) {
// Find exact match of [CODE] in title
return searchRes.data.find(t => t.title.includes(`[${featureMeta.featureCode}]`));
}
return null;
}
function deriveWorkflowPriority_(featureMeta) {
const { type, module, severity } = featureMeta;
const highPrioKeywords = ['auth', 'login', 'otp', 'admin', 'rbac', 'security', 'payment'];
const modValue = (module || '').toLowerCase();
// Rule 1: Critical severity OR critical modules = Priority 5 (Highest)
const isCriticalModule = highPrioKeywords.some(kw => modValue.includes(kw));
if (severity === 'critical' || isCriticalModule) return 5;
// Rule 2: Bugfixes OR High severity = Priority 4
if (type === 'bugfix' || severity === 'high') return 4;
// Rule 3: Features OR Improvements = Priority 3
if (type === 'feature' || type === 'improvement') return 3;
// Rule 4: Refactors OR Docs OR Low severity = Priority 2
if (type === 'refactor' || type === 'docs' || severity === 'low') return 2;
// Rule 5: Default = Priority 1 (Lowest)
return 1;
}
function deriveWorkflowLabels_(featureMeta) {
const labels = [];
if (featureMeta.type) labels.push(`type:${featureMeta.type}`);
if (featureMeta.module) labels.push(`mod:${featureMeta.module}`);
if (featureMeta.impactArea) labels.push(`impact:${featureMeta.impactArea}`);
if (featureMeta.severity) labels.push(`sev:${featureMeta.severity}`);
return labels;
}
function attachLabelsToTask_(taskId, labels) {
labels.forEach(label => {
const res = addVikunjaLabel(taskId, label);
if (!res.ok) console.warn(`Workflow Label Warning: ${res.message}`);
});
}
+761
View File
@@ -0,0 +1,761 @@
/**
* 🧪 WORKFLOW INTEGRATION TESTS
* =============================================================================
* This file contains manual test helpers to verify the connection between
* SchoolHub and external workflow tools (Vikunja & Gitea).
*
* INSTRUCTIONS:
* 1. Select the desired test function from the toolbar dropdown.
* 2. Click "Run".
* 3. Check the "Execution Log" for the results.
* =============================================================================
*/
/**
* Test 1: Verify Script Properties are readable.
* Goal: Ensure VIKUNJA_BASE_URL, VIKUNJA_TOKEN, etc., are set.
*/
function testWorkflowConfig() {
console.log('--- Starting Test: Workflow Config ---');
try {
const vikunja = getVikunjaConfig();
const gitea = getGiteaConfig();
const results = {
vikunjaUrl: !!vikunja.baseUrl,
vikunjaToken: !!vikunja.token,
giteaUrl: !!gitea.baseUrl,
giteaToken: !!gitea.token,
giteaOwner: !!gitea.owner,
giteaRepo: !!gitea.repo
};
const allPassed = Object.values(results).every(val => val === true);
if (allPassed) {
console.log('✅ PASS: All required Script Properties are present.');
} else {
console.error('❌ FAIL: Some properties are missing:');
console.log(JSON.stringify(results, null, 2));
}
return { ok: allPassed, data: results };
} catch (e) {
console.error('❌ ERROR: ' + e.message);
return { ok: false, message: e.message };
}
}
/**
* Test 2: Verify Gitea Connectivity.
* Goal: Perform a read-only request to the repository root.
*/
function testGiteaConnection() {
console.log('--- Starting Test: Gitea Connection ---');
const res = getGiteaRepo();
if (res.ok) {
console.log('✅ PASS: Gitea authenticated successfully.');
console.log('Repo Name: ' + res.data.name);
return { ok: true, data: res.data };
} else {
console.error('❌ FAIL: Gitea connection failed: ' + res.message);
return { ok: false, message: res.message };
}
}
/**
* Test 3: Verify Vikunja Connectivity using a Task ID.
* @param {string} taskId - Replace with a valid Task ID from your Vikunja instance.
*/
function testVikunjaRead() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Vikunja Read (Task: ${taskId}) ---`);
const res = getVikunjaTask(taskId);
if (res.ok) {
console.log('✅ PASS: Vikunja authenticated and task retrieved.');
console.log('Task Title: ' + (res.data.title || 'No Title'));
return { ok: true, data: res.data };
} else {
console.error('❌ FAIL: Vikunja read failed: ' + res.message);
if (res.rawBody) console.log('Raw Response: ' + res.rawBody);
return { ok: false, message: res.message };
}
}
/**
* Test 4: Verify Vikunja Write Operation.
* Goal: Add a clearly marked test comment.
* @param {string} taskId - Replace with a valid Task ID.
*/
function testVikunjaComment() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Vikunja Write (Task: ${taskId}) ---`);
const testMessage = `[TEST] Integration check from SchoolHub at ${new Date().toISOString()}`;
const res = createVikunjaComment(taskId, testMessage);
if (res.ok) {
console.log('✅ PASS: Test comment successfully created.');
return { ok: true };
} else {
console.error('❌ FAIL: Vikunja write failed: ' + res.message);
return { ok: false, message: res.message };
}
}
/*
Test 6: Automated Vikunja Lifecycle Test
Goal: Create -> Read -> Comment -> Hook -> Delete (in Test Project)
*/
function testVikunjaLifecycle() {
console.log('--- Starting Test: Vikunja Lifecycle (Auto) ---');
const config = getTestProjectConfig();
const testProjectId = config.testProjectId;
if (!testProjectId) {
console.warn('⚠️ SKIP: VIKUNJA_TEST_PROJECT_ID not found in Script Properties.');
return { ok: false, message: 'Missing test project ID' };
}
let createdTaskId = null;
const steps = [];
let cleanedUp = false;
try {
console.log('Step 1: Creating temporary task...');
const createData = {
title: `[TEST][AUTO][SchoolHub] Lifecycle Test ${new Date().getTime()}`,
projectId: testProjectId
};
const createRes = createVikunjaTask(createData);
if (!createRes.ok || !createRes.data || !createRes.data.id) {
throw new Error(`Task Creation Failed: ${createRes.message || 'No ID returned'}. Raw: ${createRes.rawBody || 'N/A'}`);
}
createdTaskId = createRes.data.id;
steps.push('TASK_CREATED');
console.log(`✅ Task created with ID: ${createdTaskId}`);
console.log('Step 2: Reading task back...');
const readRes = getVikunjaTask(createdTaskId);
if (!readRes.ok) throw new Error(`Read failed: ${readRes.message}`);
steps.push('TASK_READ');
console.log(`✅ Task verified: ${readRes.data.title}`);
console.log('Step 3: Adding test comment...');
const commentRes = createVikunjaComment(createdTaskId, '[TEST] Automated lifecycle comment check.');
if (!commentRes.ok) {
throw new Error(`Comment failed (Code: ${commentRes.code}): ${commentRes.message}. Raw: ${commentRes.rawBody}`);
}
steps.push('COMMENT_ADDED');
console.log('✅ Comment added successfully');
console.log('Step 4: Running workflow hook...');
const hookRes = workflowStartFeature('TEST-AUTO', createdTaskId);
if (!hookRes.ok) throw new Error(`Hook failed: ${hookRes.message}`);
steps.push('HOOK_EXECUTED');
console.log('✅ Workflow hook executed successfully');
return {
ok: true,
createdTaskId,
cleanedUp: cleanedUp,
steps,
message: 'Full lifecycle completed successfully'
};
} catch (e) {
console.error(`❌ Lifecycle Test Failed at step ${steps.length + 1}: ${e.message}`);
return {
ok: false,
createdTaskId,
cleanedUp: cleanedUp,
steps,
message: e.message
};
} finally {
if (createdTaskId) {
console.log('Final Step: Cleaning up temporary task...');
const delRes = deleteVikunjaTask(createdTaskId);
if (delRes.ok) {
console.log('✅ Temporary task deleted.');
cleanedUp = true;
} else {
console.error(`❌ Cleanup failed for task ${createdTaskId}: ${delRes.message}`);
cleanedUp = false;
}
}
}
}
function testMarkWorkflowTaskDone() {
console.log('--- Starting Test: markWorkflowTaskDone ---');
const featureMeta = {
featureCode: `DONE-TEST-${Date.now()}`,
title: 'Automated Done Status Test',
type: 'feature',
module: 'general'
};
try {
// 1. Ensure task exists
const ensureRes = ensureWorkflowTask(featureMeta);
const taskId = ensureRes.taskId;
console.log(`Step 1: Task created/resolved: ${taskId}`);
// 2. Mark as done
markWorkflowTaskDone_(featureMeta, true);
console.log('Step 2: markWorkflowTaskDone_ executed');
// 3. Verify status
const taskRes = getVikunjaTask(taskId);
if (!taskRes.ok) throw new Error(`Read failed: ${taskRes.message}`);
const task = taskRes.data;
assertEqual_('Task is marked done', task.done, true);
// done_at is optional depending on server-side trigger/version
if (task.done_at) {
assertTrue_('Task has done_at timestamp', !!task.done_at);
} else {
console.log('️ Note: Server did not return done_at, but task.done is true.');
}
console.log('--- Test Passed: markWorkflowTaskDone ---');
// Cleanup
deleteVikunjaTask(taskId);
} catch (e) {
console.error(`❌ Test Failed: ${e.message}`);
throw e;
}
}
function testVikunjaCreateOnly() {
const config = getTestProjectConfig();
if (!config.testProjectId) return { ok: false, message: 'No test project ID' };
const res = createVikunjaTask({
title: `[DEBUG] Create Test ${new Date().getTime()}`,
projectId: config.testProjectId,
priority: 4
});
console.log(res.ok ? `✅ Created: ${res.data && res.data.id}` : `❌ Failed: ${res.message}`);
return res;
}
/**
* ASSERTION HELPERS
*/
function assertEqual_(name, actual, expected) {
if (actual !== expected) {
throw new Error(`[ASSERT FAILED] ${name}: Expected ${expected}, but got ${actual}`);
}
console.log(`✅ ${name}: ${actual} == ${expected}`);
}
function assertArrayEqual_(name, actual, expected) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`[ASSERT FAILED] ${name}: Expected ${e}, but got ${a}`);
}
console.log(`✅ ${name}: ${a} == ${e}`);
}
function assertTrue_(name, condition) {
if (!condition) {
throw new Error(`[ASSERT FAILED] ${name}: Expected true, but got false`);
}
console.log(`✅ ${name}: true`);
}
/**
* Test: Workflow Priority Rules
* Goal: Validate that the priority mapping (5..1) follows business rules.
*/
function testWorkflowPriorityRules() {
console.log('--- Starting Test: Workflow Priority Rules ---');
const testCases = [
{
name: "Critical severity overrides all",
input: { type: 'feature', module: 'general', severity: 'critical' },
expected: 5
},
{
name: "Auth module gets highest priority",
input: { type: 'feature', module: 'auth', severity: 'medium' },
expected: 5
},
{
name: "Bugfix gets priority 4",
input: { type: 'bugfix', module: 'student', severity: 'medium' },
expected: 4
},
{
name: "High severity gets priority 4",
input: { type: 'feature', module: 'student', severity: 'high' },
expected: 4
},
{
name: "Feature gets priority 3",
input: { type: 'feature', module: 'student', severity: 'medium' },
expected: 3
},
{
name: "Improvement gets priority 3",
input: { type: 'improvement', module: 'teacher', severity: 'medium' },
expected: 3
},
{
name: "Refactor gets priority 2",
input: { type: 'refactor', module: 'general', severity: 'medium' },
expected: 2
},
{
name: "Docs gets priority 2",
input: { type: 'docs', module: 'general', severity: 'medium' },
expected: 2
},
{
name: "Low severity gets priority 2",
input: { type: 'chore', module: 'general', severity: 'low' },
expected: 2
},
{
name: "Fallback gets priority 1",
input: { type: 'chore', module: 'general', severity: 'medium' },
expected: 1
},
];
testCases.forEach(tc => {
const actual = deriveWorkflowPriority_(tc.input);
assertEqual_(tc.name, actual, tc.expected);
});
console.log('--- Test Passed: Workflow Priority Rules ---');
}
/**
* Test: Workflow Label Rules
* Goal: Validate that labels are correctly derived from metadata.
*/
function testWorkflowLabelRules() {
console.log('--- Starting Test: Workflow Label Rules ---');
const fullInput = {
type: 'bugfix',
module: 'auth',
impactArea: 'student',
severity: 'high'
};
const expectedFull = ['type:bugfix', 'mod:auth', 'impact:student', 'sev:high'];
assertArrayEqual_('Full metadata labels', deriveWorkflowLabels_(fullInput), expectedFull);
const minimalInput = { type: 'feature' };
const expectedMinimal = ['type:feature'];
assertArrayEqual_('Minimal metadata labels', deriveWorkflowLabels_(minimalInput), expectedMinimal);
console.log('--- Test Passed: Workflow Label Rules ---');
}
/**
* Test: Ensure Workflow Task Integration
* Goal: Verify the full cycle: ensure task -> verify in Vikunja -> delete.
*/
function testEnsureWorkflowTask() {
console.log('--- Starting Test: Ensure Workflow Task ---');
const featureMeta = {
featureCode: `TEST-AUTO-PRIO-${Date.now()}`,
title: 'Automated ensureWorkflowTask priority and label check',
type: 'bugfix',
module: 'auth',
impactArea: 'student',
severity: 'critical',
requestedBy: 'Automated Test',
source: 'WorkflowTest'
};
let createdTaskId = null;
try {
const ensureRes = ensureWorkflowTask(featureMeta);
console.log('Ensure Result: ' + JSON.stringify(ensureRes));
assertTrue_('Ensure task ok', ensureRes.ok);
assertTrue_('Task ID exists', !!ensureRes.taskId);
assertTrue_('Task state is valid', ['CREATED', 'EXISTING'].includes(ensureRes.state));
createdTaskId = ensureRes.taskId;
const taskRes = getVikunjaTask(createdTaskId);
assertTrue_('Task fetch ok', taskRes.ok);
assertTrue_('Task data exists', !!taskRes.data);
const task = taskRes.data;
assertTrue_('Title contains feature code', task.title.includes(`[${featureMeta.featureCode}]`));
assertEqual_('Task priority matches rule', task.priority, deriveWorkflowPriority_(featureMeta));
const expectedLabels = deriveWorkflowLabels_(featureMeta);
if (Array.isArray(task.labels)) {
const actualLabels = task.labels.map(l => l.title || l.name);
expectedLabels.forEach(el => {
assertTrue_(`Label ${el} present`, actualLabels.includes(el));
});
} else {
console.log('️ Labels not returned as array; verification skipped for this instance.');
}
console.log('--- Test Passed: Ensure Workflow Task ---');
} catch (e) {
console.error(`❌ Test Failed: ${e.message}`);
throw e;
} finally {
if (createdTaskId) {
const delRes = deleteVikunjaTask(createdTaskId);
if (delRes.ok) console.log('✅ Cleanup: Temporary task deleted.');
else console.warn('⚠️ Cleanup: Failed to delete temporary task.');
}
}
}
/**
* Test 5: Workflow Hook Dry Run.
* Goal: Verify the orchestrator can call the client.
* @param {string} taskId - Replace with a valid Task ID.
*/
function testWorkflowHooksDryRun() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Workflow Hook Dry Run (Task: ${taskId}) ---`);
const res = workflowStartFeature('TEST-001', taskId);
if (res.ok) {
console.log('✅ PASS: workflowStartFeature executed successfully.');
return { ok: true };
} else {
console.error(`❌ FAIL: Workflow hook failed: ${res.message}`);
return { ok: false, message: res.message };
}
}
/*
* 🧪 GITEA SYNC INTEGRATION TESTS
* =============================================================================
*/
function testGiteaPathMapping() {
console.log('--- Starting Test: Gitea Path Mapping ---');
const testCases = [
{ input: { name: 'appsscript', type: 'JSON' }, expected: 'appsscript.json' },
{ input: { name: 'Code', type: 'SERVER_JS' }, expected: 'app/Code.gs' },
{ input: { name: 'Index', type: 'HTML' }, expected: 'app/Index.html' },
{ input: { name: 'styles', type: 'HTML' }, expected: 'app/styles.html' },
{ input: { name: 'Readme', type: 'SERVER_JS' }, expected: 'docs/Readme.gs' },
{ input: { name: 'WorkflowConfig', type: 'SERVER_JS' }, expected: 'workflow/WorkflowConfig.gs' },
{ input: { name: 'Unknown', type: 'OTHER' }, expected: null }
];
testCases.forEach(function(tc) {
const actual = mapAppsScriptFileToRepoPath(tc.input);
assertEqual_(
`Path mapping for ${tc.input.name} (${tc.input.type})`,
actual,
tc.expected
);
});
console.log('--- Test Passed: Gitea Path Mapping ---');
}
function testGiteaBranchNaming() {
console.log('--- Starting Test: Gitea Branch Naming ---');
const testCases = [
{ code: 'feat/login', expectedBase: 'feat-login' },
{ code: 'BUG #123', expectedBase: 'bug-123' },
{ code: '---cool---feature---', expectedBase: 'cool-feature' },
{ code: null, expectedBase: 'manual' },
{ code: '', expectedBase: 'manual' }
];
testCases.forEach(function(tc) {
const branch = buildSyncBranchName(tc.code);
assertTrue_(
`Branch starts with sync/ for input: ${tc.code}`,
branch.indexOf('sync/') === 0
);
assertTrue_(
`Branch contains sanitized base "${tc.expectedBase}"`,
branch.indexOf(tc.expectedBase) !== -1
);
assertTrue_(
`Branch ends with timestamp pattern for input: ${tc.code}`,
/\d{8}-\d{4}$/.test(branch)
);
assertTrue_(
`Branch has no duplicate hyphens for input: ${tc.code}`,
!/--/.test(branch)
);
});
console.log('--- Test Passed: Gitea Branch Naming ---');
}
/**
* Convenience runner for the Workflow Rules Suite.
* Runs priority, label, and task assurance tests.
*/
function testWorkflowRulesSuite() {
console.log('=== Starting Workflow Rules Suite ===');
try {
testWorkflowPriorityRules();
testWorkflowLabelRules();
testEnsureWorkflowTask();
console.log('=== Workflow Rules Suite Passed ===');
} catch (e) {
console.error('=== Workflow Rules Suite FAILED ===');
console.error(e.message);
throw e;
}
}
function testPrepareGitSyncPayload() {
console.log('--- Starting Test: Prepare Git Sync Payload ---');
const mockFiles = [
{ name: 'appsscript', type: 'JSON', source: '{ "timeZone": "UTC" }' },
{ name: 'Code', type: 'SERVER_JS', source: 'function test() {}' },
{ name: 'Index', type: 'HTML', source: '<html></html>' },
{ name: 'InvalidFile', type: 'UNKNOWN', source: 'junk' },
{ name: 'MissingSource', type: 'SERVER_JS' },
null
];
const payload = prepareGitSyncPayload('test-feature', mockFiles);
assertTrue_('Base branch is set', !!payload.baseBranch);
assertTrue_('Sync branch starts with sync/', payload.syncBranch.indexOf('sync/') === 0);
assertTrue_('Sync branch contains feature code', payload.syncBranch.indexOf('test-feature') !== -1);
assertEqual_('Valid files mapped correctly', payload.files.length, 4);
assertEqual_('JSON mapped to appsscript.json', payload.files[0].repoPath, 'appsscript.json');
assertEqual_('SERVER_JS mapped to app/Code.gs', payload.files[1].repoPath, 'app/Code.gs');
assertEqual_('HTML mapped to app/Index.html', payload.files[2].repoPath, 'app/Index.html');
assertEqual_('SERVER_JS missing source mapped to workflow/MissingSource.gs', payload.files[3].repoPath, 'workflow/MissingSource.gs');
console.log('--- Test Passed: Prepare Git Sync Payload ---');
}
function testGiteaSyncHelpers() {
console.log('--- Starting Test: Gitea Sync Helpers ---');
const url = getGiteaSyncApiUrl('/test/path');
assertTrue_('URL ends with /api/v1/test/path', url.indexOf('/api/v1/test/path') !== -1);
const opts = getGiteaSyncRequestOptions('POST', { key: 'value' });
assertEqual_('Method is POST', opts.method, 'POST');
assertTrue_('Has Authorization header', !!opts.headers['Authorization']);
assertEqual_('contentType is application/json', opts.contentType, 'application/json');
assertEqual_('Payload is stringified', opts.payload, '{"key":"value"}');
console.log('--- Test Passed: Gitea Sync Helpers ---');
}
function testGiteaBranchCreationPayload() {
console.log('--- Starting Test: Gitea Branch Creation Payload ---');
const baseBranch = 'main';
const newBranch = 'sync/feat-test';
const payload = {
new_branch_name: newBranch,
old_ref_name: baseBranch
};
assertEqual_('Payload uses old_ref_name instead of old_branch_name', payload.old_ref_name, 'main');
assertTrue_('old_branch_name is undefined', payload.old_branch_name === undefined);
console.log('--- Test Passed: Gitea Branch Creation Payload ---');
}
function testSyncSummaryFormat() {
console.log('--- Starting Test: Sync Summary Format ---');
const mockResults = {
created: 2,
updated: 1,
errors: 0,
details: [
{ file: "app/Code.gs", ok: true, message: "" },
{ file: "appsscript.json", ok: true, message: "" },
{ file: "sync-manifest.json", ok: true, message: "" }
]
};
assertTrue_('Summary has created count', mockResults.created === 2);
assertTrue_('Summary has updated count', mockResults.updated === 1);
assertTrue_('Summary has errors count', mockResults.errors === 0);
assertEqual_('Summary details length is correct', mockResults.details.length, 3);
const mockFinalResult = {
ok: true,
branch: 'sync/manual',
summary: mockResults,
pullRequest: { url: 'https://gitea.example.com/pr/1', existed: true }
};
assertTrue_('Final result has branch info', !!mockFinalResult.branch);
assertTrue_('Final result has pullRequest info', !!mockFinalResult.pullRequest);
assertTrue_('Final result pullRequest has url', !!mockFinalResult.pullRequest.url);
assertEqual_('Final result pullRequest existed flag is true', mockFinalResult.pullRequest.existed, true);
console.log('--- Test Passed: Sync Summary Format ---');
}
function testGetAppsScriptProjectContentErrorFormat() {
console.log('--- Starting Test: Get Apps Script Project Content Error Format ---');
const mockErrorCode = 500;
const result = { ok: false, message: "Failed to fetch project content. Code: " + mockErrorCode };
assertEqual_('Result ok is false', result.ok, false);
assertEqual_('Message formatting correct', result.message, "Failed to fetch project content. Code: 500");
const result403 = {
ok: false,
message: "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.",
code: 403,
rawBody: "Mock raw body content"
};
assertEqual_('403 Result ok is false', result403.ok, false);
assertEqual_('403 Result code is 403', result403.code, 403);
assertTrue_('Message contains HTTP 403 Forbidden', result403.message.indexOf("HTTP 403 Forbidden") !== -1);
assertEqual_('rawBody is preserved', result403.rawBody, "Mock raw body content");
console.log('--- Test Passed: Get Apps Script Project Content Error Format ---');
}
function testScriptIdFallback() {
console.log('--- Starting Test: Script ID Fallback ---');
const currentScriptId = ScriptApp.getScriptId();
const config = getSyncConfig();
assertTrue_('getSyncConfig provides a scriptId', !!config.scriptId);
console.log('Current ScriptApp ID: ' + currentScriptId);
console.log('Config Script ID: ' + config.scriptId);
if (config.scriptId === currentScriptId) {
console.log('✅ Fallback working or explicitly matched.');
} else {
console.log('️ Configured scriptId is different from current ScriptApp ID.');
}
console.log('--- Test Passed: Script ID Fallback ---');
}
function testGiteaPullRequestPayload() {
console.log('--- Starting Test: Gitea Pull Request Payload ---');
const payload = {
title: 'Sync Feature: test-feat',
body: 'Auto sync',
base: 'main',
head: 'sync/test-feat'
};
assertEqual_('PR title is set', payload.title, 'Sync Feature: test-feat');
assertEqual_('PR base is main', payload.base, 'main');
assertEqual_('PR head is correct', payload.head, 'sync/test-feat');
console.log('--- Test Passed: Gitea Pull Request Payload ---');
}
function testCheckGiteaPullRequestExistsLogic() {
console.log('--- Starting Test: Check Gitea PR Exists Logic ---');
const mockPrs = [
{ base: { ref: 'main' }, head: { ref: 'sync/feat-1' } },
{ base: { ref: 'main' }, head: { ref: 'sync/feat-2' } }
];
const baseBranch = 'main';
const headBranch = 'sync/feat-2';
const existing = mockPrs.find(function(pr) {
return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch;
});
assertTrue_('Finds existing PR', !!existing);
assertEqual_('Matches correct head', existing.head.ref, 'sync/feat-2');
const notExisting = mockPrs.find(function(pr) {
return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === 'sync/feat-3';
});
assertTrue_('Does not find missing PR', !notExisting);
console.log('--- Test Passed: Check Gitea PR Exists Logic ---');
}
function testEmptyRepoErrorDetection() {
console.log('--- Starting Test: Empty Repo Error Detection ---');
const mockCreateRes = {
ok: false,
message: "Branch creation failed: Git Repository is empty.",
code: 409
};
const isRepoEmptyError = !mockCreateRes.ok && mockCreateRes.message && mockCreateRes.message.toLowerCase().indexOf('empty') !== -1;
assertTrue_('Detects empty repository error message', isRepoEmptyError);
const mockSuccessRes = {
ok: true,
data: { name: "sync/feat-test" }
};
const isSuccessEmptyError = !mockSuccessRes.ok && mockSuccessRes.message && mockSuccessRes.message.toLowerCase().indexOf('empty') !== -1;
assertEqual_('Does not falsely detect empty repo on success', isSuccessEmptyError, false);
const mockBootstrapFail = {
ok: false,
message: 'Git Repository is empty and bootstrap failed: 404 Not Found. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).'
};
const detectsManualInit = !mockBootstrapFail.ok && mockBootstrapFail.message.toLowerCase().indexOf('manual') !== -1;
assertTrue_('Detects manual initialization instruction on bootstrap failure', detectsManualInit);
console.log('--- Test Passed: Empty Repo Error Detection ---');
}