Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00a1e954e4 | |||
| 63fb07be30 | |||
| 5bed026e6d | |||
| a641a8b9cd | |||
| 8781fa1047 | |||
| 0da147694d | |||
| 7695ad623d | |||
| bc04ac144e | |||
| 0f06c4fcf4 | |||
| c202e207c9 | |||
| af530ed2e5 | |||
| 9569033dc1 | |||
| fbfeff2b27 |
@@ -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"
|
||||
]
|
||||
}
|
||||
+660
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
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';
|
||||
}
|
||||
if (file.type === 'SERVER_JS') {
|
||||
return 'src/' + file.name + '.gs';
|
||||
}
|
||||
if (file.type === 'HTML') {
|
||||
return 'src/' + file.name + '.html';
|
||||
}
|
||||
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.');
|
||||
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;
|
||||
}
|
||||
+912
@@ -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">​</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">​</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">​</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">​</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">​</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>
|
||||
@@ -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.
|
||||
*
|
||||
*
|
||||
* =============================================================================
|
||||
*/
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* 🧪 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: 'src/Code.gs' },
|
||||
{ input: { name: 'Index', type: 'HTML' }, expected: 'src/Index.html' },
|
||||
{ input: { name: 'styles', type: 'HTML' }, expected: 'src/styles.html' },
|
||||
{ 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' }, // valid file type mapping, missing source resolves to empty string
|
||||
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 src/Code.gs', payload.files[1].repoPath, 'src/Code.gs');
|
||||
assertEqual_('HTML mapped to src/Index.html', payload.files[2].repoPath, 'src/Index.html');
|
||||
assertEqual_('SERVER_JS missing source mapped to src/MissingSource.gs', payload.files[3].repoPath, 'src/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: "src/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 ---');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"featureCode": "manual",
|
||||
"syncDate": "2026-04-24T22:40:04.295Z",
|
||||
"filesSynced": 12
|
||||
}
|
||||
Reference in New Issue
Block a user