# Taskboard referencia alkalmazás ülések, tulajdon és tartós feladatok

> Építsen és teszteljék egy kis használható alkalmazást aláírt ülésekkel, scrypt jelszavakkal, CSRF-vel, két szereppel, tulajdonos-capped NoSQL CRUD-val és böngészőfelületgel.

Framework: total5
Product: Total.js 5 (total5@0.0.19)
Language: hu
Content-SHA256: e16ccde632d61b842eeaf4784f522f63cb19f7c894bd28890e0d0a95c6611dfd
Snapshot: c0700e321579725c5cf4f5ace1ad7d2866d1b4f93c24a63c0b41b6b7998e976c
Sources: total5-installed@total5@0.0.19
Review: not-recorded

Ez a referencia egy üzleti alkalmazás darabjait fogja össze: egy névtelen látogató megnyitja a felületet, bejelentkezik, feladatokat hoz létre, késznek jelöli őket és kijelentkezik. A tagok csak a saját feladataikat látják; egy adminisztrátor bármelyik feladatot listázhatja és törölheti. A szerver birtokolja az összes jogosultsági döntést, függetlenül attól, hogy a böngésző mely gombokat jeleníti meg.

Az alkalmazás natív Total.js útvonalakat, akciókat, sémákat, munkamenet-hitelesítést és beágyazott NoSQL-t használ. A jelszavak a Node scrypt függvényét használják a `Total.Crypto` segítségével. A felület hétköznapi böngészőűrlapokat és DOM szöveges csomópontokat használ; nincs szükség más frontend keretrendszerre vagy futásidejű függőségre. A közvetlen vezérlők kezelik a munkamenet szállítását; az újrafelhasználható feladat-akciók stabil `Namespace|action` azonosítókat használnak.

## Futtasd a teljes példát

Másold az alább megnevezett fájlokat egy új könyvtárba, add hozzá a `total5@0.0.19` csomagot a package.json-hoz, és indítsd el a `require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });` hívással. Nyisd meg a localhost:18400 címet. Használj a Forge-tól eltérő könyvtárat és portot. A tároló exportáló eszköze ezt a pontos példát projektként is létre tudja hozni.

A `demo: true` engedélyezi a három helyi felhasználót, `alice`, `bob` és `admin`, az `alice-demo-password`, `bob-demo-password` és `admin-demo-password` jelszavakkal. A demó mód nem hajlandó elindulni, ha `--release` vagy `NODE_ENV=production` van megadva. Tartsd a figyelőjét a loopbackhez kötve. A keretrendszer a `release:false` megadása esetén is `DEBUG=false` értéket állít be `watcher:false` mellett; ezért ez a példa nem használja a `DEBUG` értéket demóőrként. Release esetén állítsd be a `demo: false` értéket, add meg a `TASKBOARD_SECRET` (legalább 32 karakter) és a `TASKBOARD_ALICE_PASSWORD`, `TASKBOARD_BOB_PASSWORD`, `TASKBOARD_ADMIN_PASSWORD` (egyenként legalább 12 karakter) értékeket. A rögzített demó hitelesítő adatok nem használhatók nyilvános telepítéshez.

A felhasználók szándékosan kicsi, rögzített könyvtár; a munkamenetek és a feladatok a `databases/` helyen maradnak meg. Egy stabil titok lehetővé teszi, hogy egy érvényes munkamenet újraindítás után újra betöltődjön. A kijelentkezés megvárja a munkamenet-tárolóból való eltávolítás callbackjét, mielőtt sikert ad vissza. Ne oszd meg ezt a könyvtárat futó példányok között, ne töröld telepítéskor, és ne tedd gitbe. Egy nyilvános telepítéshez HTTPS, megbízható reverse proxy, biztonsági mentések és valódi felhasználó-ellátási szabályzat is kell. Ez egy futtatható referencia, nem teljes identitáskezelő termék.

## Szerverfájlok

```text config
name : Taskboard reference
demo : true
```

```js definitions/taskboard.js
var crypto = Total.Crypto;
var demo = CONF.demo === true;
if (demo && (process.argv.includes('--release') || process.env.NODE_ENV === 'production'))
	throw new Error('Disable demo mode before a release deployment');
var secret = process.env.TASKBOARD_SECRET || (demo ? 'local-taskboard-demo-secret-not-for-public-use' : '');
if (secret.length < 32)
	throw new Error('TASKBOARD_SECRET needs at least 32 characters');
CONF.secret_csrf = secret;

MAIN.taskUsers = Object.create(null);
for (var name of ['alice', 'bob', 'admin']) {
	var password = process.env['TASKBOARD_' + name.toUpperCase() + '_PASSWORD'] || (demo ? name + '-demo-password' : '');
	if (password.length < 12)
		throw new Error('Configure a password of at least 12 characters for ' + name);
	var salt = crypto.randomBytes(16).toString('hex');
	MAIN.taskUsers[name] = { id: name, name: name, role: name === 'admin' ? 'admin' : 'member', salt: salt, hash: crypto.scryptSync(password, salt, 32) };
}

MAIN.taskAuth = {
	secret: secret,
	cookie: 'taskboard_session',
	expire: '30 minutes',
	options: { httponly: true, samesite: 'Strict', secure: !demo },
	onread: function(meta, next) {
		NOSQL('task-sessions').read().where('id', meta.sessionid).callback(function(err, session) {
			var user = !err && session && session.expire > Date.now() && MAIN.taskUsers[session.userid];
			user ? next(null, { id: user.id, name: user.name, role: user.role }) : next('Session not found');
		});
	},
	onlogout: function(session) {
		NOSQL('task-sessions').remove().where('id', session.sessionid).callback(NOOP);
	},
	onremove: function(id) {
		NOSQL('task-sessions').remove().where('id', id).callback(NOOP);
	}
};
AUTH(MAIN.taskAuth);

MIDDLEWARE('taskcsrf', function($, next) {
	DEF.onCSRFcheck($) ? next() : $.invalid(403);
});
```

```js actions/tasks.js
function owned(builder, user) {
	return user.role === 'admin' ? builder : builder.where('owner', user.id);
}

NEWACTION('Tasks|list', {
	user: true,
	action: function($) {
		owned(NOSQL('tasks').find(), $.user).sort('title').callback(function(err, tasks) {
			err ? $.invalid(500) : $.callback(tasks);
		});
	}
});

NEWACTION('Tasks|read', {
	user: true,
	params: '*id:UID',
	action: function($) {
		owned(NOSQL('tasks').read().where('id', $.params.id), $.user).callback(function(err, task) {
			if (err) return $.invalid(500);
			task ? $.callback(task) : $.invalid(404);
		});
	}
});

NEWACTION('Tasks|create', {
	user: true,
	input: '*title:String(80)',
	action: function($, model) {
		var task = { id: UID(), title: model.title, done: false, owner: $.user.id };
		NOSQL('tasks').insert(task).callback(function(err) {
			err ? $.invalid(500) : $.callback(task);
		});
	}
});

NEWACTION('Tasks|update', {
	user: true,
	params: '*id:UID',
	input: '*done:Boolean',
	action: function($, model) {
		owned(NOSQL('tasks').update({ done: model.done }).where('id', $.params.id), $.user).callback(function(err, count) {
			if (err) return $.invalid(500);
			count ? $.success() : $.invalid(404);
		});
	}
});

NEWACTION('Tasks|remove', {
	user: true,
	params: '*id:UID',
	action: function($) {
		if ($.user.role !== 'admin') return $.invalid(403);
		NOSQL('tasks').remove().where('id', $.params.id).callback(function(err, count) {
			if (err) return $.invalid(500);
			count ? $.success() : $.invalid(404);
		});
	}
});
```

```js controllers/taskboard.js
var attempts = Object.create(null);

exports.install = function() {
	ROUTE('GET /', function($) { $.file(PATH.public('index.html')); });
	ROUTE('GET /session/', session);
	ROUTE('POST /login/ @json <2kb #taskcsrf', login);
	ROUTE('+POST /logout/ @json #taskcsrf', logout);
	ROUTE('+GET /tasks/ --> *Tasks|list');
	ROUTE('+GET /tasks/{id}/ --> *Tasks|read');
	ROUTE('+POST /tasks/ <2kb #taskcsrf --> *Tasks|create');
	ROUTE('+PATCH /tasks/{id}/ <2kb #taskcsrf --> *Tasks|update');
	ROUTE('+DELETE /tasks/{id}/ #taskcsrf --> *Tasks|remove');
};

function session($) {
	$.response.headers['cache-control'] = 'no-store';
	$.json({ user: $.user || null, csrf: $.csrf() });
}

function login($) {
	var key = $.ip;
	var recent = (attempts[key] || []).filter(time => time > Date.now() - 600000);
	attempts[key] = recent;
	if (recent.length >= 5) return $.invalid(429);
	var user = MAIN.taskUsers[String($.body.name || '').toLowerCase()];
	var salt = user ? user.salt : 'dummy-salt';
	var actual = Total.Crypto.scryptSync(String($.body.password || ''), salt, 32);
	var valid = user && Total.Crypto.timingSafeEqual(actual, user.hash);
	if (!valid) {
		recent.push(Date.now());
		return $.invalid(401);
	}
	var id = UID();
	NOSQL('task-sessions').insert({ id: id, userid: user.id, expire: Date.now() + 1800000 }).callback(function(err) {
		if (err) return $.invalid(500);
		MAIN.taskAuth.authcookie($, id, user.id, '30 minutes');
		$.json({ user: { id: user.id, name: user.name, role: user.role }, csrf: $.csrf() });
	});
}

function logout($) {
	NOSQL('task-sessions').remove().where('id', $.sessionid).callback(function(err) {
		if (err) return $.invalid(500);
		MAIN.taskAuth.logout($);
		$.cookie(MAIN.taskAuth.cookie, '', '-1 year', MAIN.taskAuth.options);
		$.json({ ok: true });
	});
}
```

```html public/index.html
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Taskboard · Total.js</title>
<style>
body{font:16px system-ui;background:#f4f5f7;color:#172033;margin:0}main{max-width:720px;margin:8vh auto;padding:30px;background:white;border-radius:18px}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{margin:0}p{color:#536078}label{display:block;margin-top:14px}input,button{font:inherit;padding:10px;border:1px solid #bbc4d1;border-radius:8px}input[type=text],input[type=password]{box-sizing:border-box;width:100%;margin-top:5px}button{cursor:pointer;background:#152d57;color:white}form button{margin-top:15px}ul{padding:0;list-style:none}li{display:flex;align-items:center;gap:10px;padding:14px 0;border-bottom:1px solid #eee}.task-title{flex:1;overflow-wrap:anywhere}.muted{font-size:14px;color:#536078}#message{min-height:24px;color:#9d251e}[hidden]{display:none!important}
</style></head><body><main>
<header><h1>Taskboard</h1><span class="muted">Total.js reference</span></header>
<p>A small workspace with private tasks and a shared administrator view.</p>
<p id="message" role="status" aria-live="polite"></p>
<form id="login"><h2>Sign in</h2><label for="name">User</label><input id="name" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" required><button>Sign in</button></form>
<section id="workspace" hidden><header><p id="identity"></p><button id="logout" type="button">Sign out</button></header>
<form id="create"><label for="title">New task</label><input id="title" type="text" maxlength="80" required><button>Add task</button></form>
<ul id="tasks" aria-label="Tasks"></ul><p id="empty">No tasks yet.</p></section>
</main><script src="/app.js"></script></body></html>
```

```js public/app.js
(function() {
	var user = null;
	var csrf = '';
	var busy = false;
	var byid = id => document.getElementById(id);
	async function request(path, method, body) {
		var response = await fetch(path, { method: method || 'GET', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf }, body: body === undefined ? undefined : JSON.stringify(body) });
		var data = await response.json().catch(() => null);
		if (!response.ok) {
			if (response.status === 401) { user = null; show(); }
			throw new Error('Request refused (' + response.status + ')');
		}
		if (data === null) throw new Error('Unexpected server response');
		return data;
	}
	function show() {
		byid('login').hidden = !!user;
		byid('workspace').hidden = !user;
		byid('identity').textContent = user ? user.name + ' · ' + user.role : '';
	}
	async function load() {
		var tasks = await request('/tasks/');
		byid('tasks').replaceChildren();
		byid('empty').hidden = tasks.length > 0;
		tasks.forEach(function(task) {
			var row = document.createElement('li');
			var checkbox = document.createElement('input');
			checkbox.type = 'checkbox'; checkbox.checked = task.done; checkbox.setAttribute('aria-label', 'Complete ' + task.title);
			checkbox.addEventListener('change', () => work(async () => { await request('/tasks/' + task.id + '/', 'PATCH', { done: checkbox.checked }); await load(); }));
			var title = document.createElement('span'); title.className = 'task-title'; title.textContent = task.title;
			row.append(checkbox, title);
			if (user.role === 'admin') {
				var remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete';
				remove.addEventListener('click', () => work(async () => { await request('/tasks/' + task.id + '/', 'DELETE'); await load(); }));
				row.append(remove);
			}
			byid('tasks').append(row);
		});
	}
	async function work(fn) {
		if (busy) return;
		busy = true; byid('message').textContent = '';
		document.querySelectorAll('input,button').forEach(control => control.disabled = true);
		try { await fn(); } catch (err) { byid('message').textContent = err.message; }
		finally { busy = false; document.querySelectorAll('input,button').forEach(control => control.disabled = false); }
	}
	byid('login').addEventListener('submit', function(event) {
		event.preventDefault(); work(async function() {
			var result = await request('/login/', 'POST', { name: byid('name').value, password: byid('password').value });
			user = result.user; csrf = result.csrf; byid('password').value = ''; show(); await load();
		});
	});
	byid('create').addEventListener('submit', function(event) {
		event.preventDefault(); work(async function() { await request('/tasks/', 'POST', { title: byid('title').value }); byid('title').value = ''; await load(); });
	});
	byid('logout').addEventListener('click', () => work(async function() { await request('/logout/', 'POST', {}); user = null; byid('tasks').replaceChildren(); show(); }));
	work(async function() { var result = await request('/session/'); user = result.user; csrf = result.csrf; show(); if (user) await load(); });
})();
```

## Mit gyakorol, és mi marad hátra


A fájlmellékletek és a valós idejű frissítések a [Taskboard Live](/en/docs/taskboard-live/) oldalon folytatódnak. A regisztráció, a jelszó-visszaállítás, több alkalmazáspéldány és egy SQL driver követő példák saját szerződésekkel, nem ennek az alkalmazásnak a vélelmezett funkciói. Kapcsolódó receptek: [munkamenet-hitelesítés](/en/total5/sessions-with-built-in-auth/), [CSRF](/en/total5/csrf-protection/), [CRUD akciók](/en/total5/actions-crud/) és [WebSocket](/en/total5/websocket-chat/).
