Initial commit

This commit is contained in:
Chord 2019-12-30 09:27:49 -05:00
commit 20946fdb43
49 changed files with 2393 additions and 0 deletions

106
app/App.svelte Normal file
View file

@ -0,0 +1,106 @@
<script>
// V1: Homepage (unauth, server list/status)
// V1: Logged in home page (character stats)
// - World server list with TR/VS/NC breakdown
// - Extra: Server stats
// - Extra: World map with empire colored grid
// - Extra: Instant action (psforever://server_name?charid=123&loc=123)
// V1: Profile (change pw, email)
// V1: Login (username, password)
// V1: Forgot password
// V1: Register (un, pw, email, captcha, email verification)
// Extra: Notifications / announcements
import { onMount } from 'svelte';
import page from 'page';
import { fade } from 'svelte/transition';
import { get_initial_state } from './UserState.js';
import Nav from './components/Nav.svelte'
import Alert from './components/Alert.svelte'
import Home from './views/Home.svelte';
import Login from './views/Login.svelte';
import Register from './views/Register.svelte';
import Profile from './views/Profile.svelte';
import BadRoute from './views/BadRoute.svelte';
import UserList from './views/UserList.svelte';
import AdminPanel from './views/AdminPanel.svelte';
import CharacterList from './views/CharacterList.svelte';
// prevent pop-in
let initialized = false;
onMount(async () => {
await get_initial_state()
initialized = true;
});
let route;
let routeParams;
let pageCtx;
let appAlert;
let previousCtx = null
let currentCtx = null
//$ : console.log("INIT " + initialized, currentCtx.pageCtx)
function setRoute(r, initialState) {
return function(ctx) {
let first = !currentCtx
if (!first)
previousCtx = currentCtx
if (!first && currentCtx.route == r)
return
if (appAlert)
appAlert.message("");
if (initialState !== undefined && initialState)
initialized = false;
else
initialized = true;
currentCtx = {
route : r,
routeParams : ctx.params,
pageCtx : ctx,
}
};
}
page("/", setRoute(Home, true));
page("/login", setRoute(Login, true));
page("/register", setRoute(Register));
page("/register", setRoute(Register));
//page("/users", setRoute(UserList));
//page("/characters", setRoute(CharacterList));
page("/admin", setRoute(AdminPanel));
//page("/recovery", setRoute(Recovery));
page("/profile", setRoute(Profile, true));
page("*", setRoute(BadRoute));
page()
</script>
<Nav bind:route={currentCtx.pageCtx.pathname}/>
<main role="main" class="container">
<Alert bind:this={appAlert} />
<div class:d-none={!initialized}>
<svelte:component this={currentCtx.route} bind:pageCtx={currentCtx.pageCtx} bind:ready={initialized} bind:appAlert={appAlert} bind:params={currentCtx.routeParams} />
</div>
</main>
<footer class="footer">
<div class="container text-center">
<span class="text-muted">
&copy;2019, PSForever.net, All Rights Reserved.<br/>
PlanetSide is a registered trademark of Daybreak Game Company, LLC. PSForever claims no such trademarks.<br/>
All other trademarks or tradenames are properties of their respective owners.
</span>
</div>
</footer>

52
app/UserState.js Normal file
View file

@ -0,0 +1,52 @@
import { writable } from 'svelte/store';
import page from 'page';
import axios from 'axios'
export const loggedIn = writable(false);
export const username = writable("");
export const isAdmin = writable(false);
export const userId = writable(0);
function clear_user_state() {
loggedIn.set(false)
username.set("")
isAdmin.set(false)
userId.set(0)
}
export async function logout() {
try {
await axios.post("/api/logout")
} catch (e) {
}
clear_user_state();
page("/")
}
loggedIn.subscribe((v) => {
console.log(loggedIn, v)
})
export async function get_initial_state() {
try {
const resp = await axios.get("/api/user")
loggedIn.set(true);
username.set(resp.data.name);
isAdmin.set(resp.data.admin);
userId.set(resp.data.id);
return true;
} catch (e) {
if (e.response.status === 403) {
console.log("User not logged in / not admin!")
clear_user_state();
return false;
} else {
console.log("Unknown login error", e)
}
return false
}
}

View file

@ -0,0 +1,24 @@
<script>
import { isAdmin } from '../UserState'
export let account;
</script>
<style>
.banned-account {
color: red;
text-decoration: line-through;
}
</style>
<span class="account-link">
{#if $isAdmin}
<a class:banned-account={account.inactive} href="/user/{account.id}">{account.name}</a>
{:else}
<span class:banned-account={account.inactive}>{account.name}</span>
{/if}
{#if account.admin}
<span class="badge badge-success">GM</span>
{/if}
</span>

View file

@ -0,0 +1,22 @@
<script>
import { fade } from 'svelte/transition';
export function message(e, ehtml) {
errorMessage = e ? e : "";
errorMessageHTML = ehtml ? ehtml : "";
shake = true;
setTimeout(() => {shake = false;}, 800)
}
let errorMessage = ""
let errorMessageHTML = ""
let shake = false;
</script>
{#if errorMessage || errorMessageHTML}
<div in:fade class:notification-shake={shake} class="alert alert-danger" role="alert">
{errorMessage}
{@html errorMessageHTML}
</div>
{/if}

View file

@ -0,0 +1,20 @@
<script>
import { isAdmin } from '../UserState'
export let character;
</script>
<span class="character-link">
{#if character.faction_id == 1}
<img height=24 src="/img/nc_icon.png" alt="NC" />
{:else if character.faction_id == 0}
<img height=32 src="/img/tr_icon.png" alt="TR" />
{:else if character.faction_id == 2}
<img height=32 src="/img/vs_icon.png" alt="VS" />
{/if}
{#if $isAdmin}
<a href="/character/{character.id}">{character.name}</a>
{:else}
<span>{character.name}</span>
{/if}
</span>

View file

@ -0,0 +1,20 @@
<script>
import { onMount } from 'svelte'
import axios from 'axios'
import CharacterLink from '../components/CharacterLink'
import moment from 'moment'
onMount(async () => {
axios.get("/api/user/"+userId+"/characters
});
</script>
{#if characters}
{#each characters as char, i}
<ul>
<li><CharacterLink character={char} /></li>
</ul>
{/each}
{/if}

View file

@ -0,0 +1,89 @@
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
const progress = tweened(0, {
duration: 1000,
easing: cubicOut
});
export let stats = { "TR" : 0, "NC" : 0, "VS" : 0};
let total = stats.TR + stats.NC + stats.VS;
let percentages = { "TR" : stats.TR/total,
"NC" : stats.NC/total,
"VS" : stats.VS/total}
let tr, nc, vs;
onMount(() => {
setTimeout(() => progress.set(1.0), 100);
tr.style.height = "1px";
nc.style.height = "1px";
vs.style.height = "1px";
})
progress.subscribe((v) => {
if (tr === undefined || !tr.style)
return
tr.style.height = v*percentages.TR*200 + "px";
nc.style.height = v*percentages.NC*200 + "px";
vs.style.height = v*percentages.VS*200 + "px";
})
</script>
<style>
.empire-stats {
background: black;
height: 200px;
width: 220px;
border: 1px solid white;
display: inline-block;
position: relative;
}
.empire-stat {
border: 1px solid white;
border-bottom: 0;
position: absolute;
bottom: 0;
text-align: center;
min-width: 50px;
min-height: 3em;
}
.empire-stat:nth-child(2) {
left: 10%;
}
.empire-stat:nth-child(3) {
left: 40.0%;
}
.empire-stat:nth-child(4) {
left: 70.0%;
}
.empire-stats-header {
display: inline;
position: absolute;
width: 100%;
padding-top: 3px;
padding-bottom: 3px;
background: white;
border-bottom: 3px solid black;
font-size: 1.0em;
color: black;
text-align: center;
z-index: 0;
}
</style>
<div class="empire-stats clearfix">
<div class="empire-stats-header">Empire Need</div>
<div title={stats.TR} bind:this={tr} class="empire-stat faction-tr-bg"><strong>TR</strong><br/>{Math.round(percentages.TR*100*$progress)}%</div>
<div title={stats.NC} bind:this={nc} class="empire-stat faction-nc-bg"><strong>NC</strong><br/>{Math.round(percentages.NC*100*$progress)}%</div>
<div title={stats.VS} bind:this={vs} class="empire-stat faction-vs-bg"><strong>VS</strong><br/>{Math.round(percentages.VS*100*$progress)}%</div>
</div>

View file

@ -0,0 +1,37 @@
<script>
import { onMount } from 'svelte'
import PaginatedList from './PaginatedList'
import axios from 'axios'
import moment from 'moment'
export let account_id;
async function fetch(page) {
try {
const resp = await axios.get("/api/user/" + account_id + "/logins?page="+page);
return [resp.data.logins, resp.data.page];
} catch (e) {
console.log(e)
return undefined;
}
}
onMount(async () => {
});
</script>
<PaginatedList {fetch} let:data={logins} let:pagination={pagination}>
<table slot="body" class="table table-dark table-responsive">
<thead>
<td>Login Time</td>
</thead>
<tbody>
{#each logins as login, i}
<tr>
<td>{moment(login.login_time).fromNow()}</td>
</tr>
{/each}
</tbody>
</table>
</PaginatedList>

48
app/components/Nav.svelte Normal file
View file

@ -0,0 +1,48 @@
<script>
import { get_initial_state, logout, isAdmin, loggedIn, username } from '../UserState.js';
import axios from 'axios'
export let route;
export let pageCtx;
console.log(pageCtx)
</script>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark justify-content-between">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/img/logo_crop.png" height="30" class="d-inline-block align-top" alt="">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item" class:active={route=="/"}>
<a class="nav-link" href="/">Server Status</a>
</li>
{#if $isAdmin}
<li class="nav-item" class:active={route=="/admin"}>
<a class="nav-link" style="color: red;" href="/admin">Admin Panel</a>
</li>
{/if}
</ul>
<ul class="navbar-nav">
{#if $loggedIn}
<li class="nav-item" class:active={route=="/profile"}>
<span class="navbar-text">Welcome <a href="/profile">{$username}</a>!</span>
</li>
<li class="nav-item">
<a class="nav-link" on:click|preventDefault={logout} href="/logout">Logout</a>
</li>
{:else}
<li class="nav-item" class:active={route=="/login"}>
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item" class:active={route=="/register"}>
<a class="nav-link" href="/register">Register</a>
</li>
{/if}
</ul>
</div>
</div>
</nav>

View file

@ -0,0 +1,57 @@
<script>
import { onMount } from 'svelte'
import axios from 'axios'
import Pagination from '../components/Pagination'
export let fetch;
let data;
let fetching = false;
let pagination = { page: 1 };
export async function refresh() {
await list_fetch(pagination.page)
}
onMount(async () => {
const url = new URL(window.location.href)
let page = url.searchParams.get('page')
if (page == undefined)
page = 1;
await list_fetch(page);
})
async function pageChange(page) {
if (pagination.page == page || fetching)
return
await list_fetch(page);
}
async function list_fetch(page) {
fetching = true;
try {
if (fetch != undefined) {
const results = await fetch(page)
if (results != undefined) {
data = results[0];
pagination = results[1]
}
}
} finally {
fetching = false;
}
}
</script>
{#if data}
<slot name="header" data={data} pagination={pagination}></slot>
<Pagination {pagination} {pageChange} />
<slot name="body" data={data} pagination={pagination}></slot>
<Pagination {pagination} {pageChange} />
<slot name="footer" data={data} pagination={pagination}></slot>
{/if}

View file

@ -0,0 +1,80 @@
<script>
export let pagination;
export let pageChange;
let numPages = 10;
let pages = []
$ : {
const new_pages = [];
let pi = 0, i;
let pg = pagination;
const pageChunk = Math.max(Math.ceil(numPages/3), 1);
const middleChunk = Math.max(Math.ceil(pageChunk/2), 1);
const leftBound = Math.min(pageChunk+1, pagination.page_count);
const rightBound = Math.max(pagination.page_count-pageChunk, 1);
// fast path: draw all pages
if (pg.page_count <= numPages || rightBound <= leftBound) {
for (i = 1; i <= pg.page_count; i++)
new_pages[pi++] = i;
} else {
let middleLeft = Math.max(pg.page-middleChunk, leftBound);
let middleRight = Math.min(pg.page+middleChunk, rightBound);
// left and middle chunks are joined
if (middleLeft == leftBound) {
middleLeft += 1;
middleRight = Math.min(middleLeft+pageChunk, rightBound);
// middle and right chunks are joined
} else if (middleRight == rightBound) {
middleRight -= 1;
middleLeft = Math.min(middleRight-middleChunk, rightBound);
}
//console.log("[1-"+leftBound+"]", "["+middleLeft+"-"+middleRight+"]", "["+rightBound+"-"+pagination.page_count+"]");
// left chunk
for (i = 1; i <= leftBound; i++) new_pages[pi++] = i;
if (leftBound+1 != middleLeft) new_pages[pi++] = -1;
// middle chunk
for (i = middleLeft; i <= middleRight; i++) new_pages[pi++] = i;
// right chunk
if (middleRight+1 != rightBound) new_pages[pi++] = -1;
for (i = rightBound; i <= pg.page_count; i++) new_pages[pi++] = i;
}
pages = new_pages
//console.log(pages);
}
</script>
<p>Displaying {(pagination.page-1)*pagination.items_per_page+1} &mdash; {Math.min(pagination.page*pagination.items_per_page, pagination.item_count)}</p>
<nav aria-label="Page navigation">
<ul class="pagination pagination-sm">
<li class="page-item" class:disabled={pagination.page<=1}>
<a class="page-link" href={"?page="+(pagination.page-1)}
on:click={(e) => pageChange(pagination.page-1)}
aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{#each pages as page,i}
{#if page == -1}
<li class="page-item page-last-separator disabled" ><a class="page-link">...<a></li>
{:else}
<li class="page-item" class:active={page==pagination.page}><a on:click={(e) => pageChange(page)} href={"?page="+page} class="page-link">{page}</a></li>
{/if}
{/each}
<li class="page-item" class:disabled={pagination.page>=pagination.page_count}>
<a class="page-link" href={"?page="+(pagination.page+1)}
on:click={(e) => pageChange(pagination.page+1)}
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>

10
app/main.js Normal file
View file

@ -0,0 +1,10 @@
import 'bootstrap';
import '../scss/main.scss';
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;

68
app/util/form.js Normal file
View file

@ -0,0 +1,68 @@
// Thanks to https://lengstorf.com/get-form-values-as-json/
/**
* Checks that an element has a non-empty `name` and `value` property.
* @param {Element} element the element to check
* @return {Bool} true if the element is an input, false if not
*/
const isValidElement = element => {
return element.name && element.value;
};
/**
* Checks if an elements value can be saved (e.g. not an unselected checkbox).
* @param {Element} element the element to check
* @return {Boolean} true if the value should be added, false if not
*/
const isValidValue = element => {
return (!['checkbox', 'radio'].includes(element.type) || element.checked);
};
/**
* Checks if an input is a checkbox, because checkboxes allow multiple values.
* @param {Element} element the element to check
* @return {Boolean} true if the element is a checkbox, false if not
*/
const isCheckbox = element => element.type === 'checkbox';
/**
* Checks if an input is a `select` with the `multiple` attribute.
* @param {Element} element the element to check
* @return {Boolean} true if the element is a multiselect, false if not
*/
const isMultiSelect = element => element.options && element.multiple;
/**
* Retrieves the selected options from a multi-select as an array.
* @param {HTMLOptionsCollection} options the options for the select
* @return {Array} an array of selected option values
*/
const getSelectValues = options => [].reduce.call(options, (values, option) => {
return option.selected ? values.concat(option.value) : values;
}, []);
/**
* Retrieves input data from a form and returns it as a JSON object.
* @param {HTMLFormControlsCollection} elements the form elements
* @return {Object} form data as an object literal
*/
export const formToJSON = form => [].reduce.call(form.getElementsByTagName("*"), (data, element) => {
// Make sure the element has the required properties and should be added.
if (isValidElement(element) && isValidValue(element)) {
/*
* Some fields allow for more than one value, so we need to check if this
* is one of those fields and, if so, store the values as an array.
*/
if (isCheckbox(element)) {
data[element.name] = (data[element.name] || []).concat(element.value);
} else if (isMultiSelect(element)) {
data[element.name] = getSelectValues(element);
} else {
data[element.name] = element.value;
}
}
return data;
}, {});

View file

@ -0,0 +1,70 @@
<script>
import UserList from '../views/UserList'
import CharacterList from '../views/CharacterList'
import CharacterLink from '../components/CharacterLink'
import AccountLink from '../components/AccountLink'
import axios from 'axios'
export let appAlert;
let results;
async function submitSearch(event) {
const value = event.target.search.value;
try {
const resp = await axios.post("/api/search", { search : value })
results = resp.data.items;
} catch (e) {
appAlert.message(e.message)
}
}
</script>
<svelte:head>
<title>PSForever - Admin Panel</title>
</svelte:head>
<h1>Admin Panel</h1>
<!--<strong>Last account created:</strong> <AccountLink account={stats.last.account} /> (<span title={moment(stats.last.account.created).format(`MMMM Do YYYY, h:mm:ss a`)}>{moment(stats.last.account.created).fromNow()}</span>)<br/>-->
<ul class="nav nav-tabs mb-3" id="nav-tab" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="search-tab" data-toggle="tab" href="#search" role="tab" aria-controls="search" aria-selected="true">Search</a>
</li>
<li class="nav-item">
<a class="nav-link" id="users-tab" data-toggle="tab" href="#users" role="tab" aria-controls="home" aria-selected="false">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" id="characters-tab" data-toggle="tab" href="#characters" role="tab" aria-controls="profile" aria-selected="false">Characters</a>
</li>
</ul>
<div class="tab-content" id="tabs-tabContent">
<div class="tab-pane fade show active" id="search" role="tabpanel" aria-labelledby="search-tab">
<form name="search" class="form-inline" on:submit|preventDefault={submitSearch}>
<div class="form-group mx-sm-3 mb-2">
<label for="inputSearch" class="sr-only">Search</label>
<input type="text" class="form-control" id="inputSearch" name="search" placeholder="Search" minlength=3 required>
</div>
<button type="submit" class="btn btn-primary mb-2">Search</button>
</form>
{#if results}
<ol>
{#each results as result, i}
{#if result.type == "account"}
<li><AccountLink account={result} /></li>
{:else if result.type == "character"}
<li><CharacterLink character={result} /></li>
{/if}
{/each}
</ol>
{/if}
</div>
<div class="tab-pane fade" id="users" role="tabpanel" aria-labelledby="users-tab">
<UserList {appAlert} />
</div>
<div class="tab-pane fade" id="characters" role="tabpanel" aria-labelledby="characters-tab">
<CharacterList {appAlert} />
</div>
</div>

17
app/views/BadRoute.svelte Normal file
View file

@ -0,0 +1,17 @@
<script>
import { onMount } from 'svelte';
import axios from 'axios'
export let params;
onMount(async () => {
try {
await axios.post("/api/bad_route", {"route": params[0]})
} catch (e) {
}
});
</script>
<div class="mt-3 text-center">
<img alt="404" src="/img/404.png" />
<h1>404 - PlanetSide Not Found</h1>
</div>

View file

@ -0,0 +1,45 @@
<script>
import { onMount } from 'svelte'
import axios from 'axios'
import CharacterLink from '../components/CharacterLink'
import PaginatedList from '../components/PaginatedList'
import moment from 'moment'
export let appAlert
async function fetch(page) {
try {
const resp = await axios.get("/api/characters?page="+page)
appAlert.message("")
return [resp.data.characters, resp.data.page];
} catch (e) {
appAlert.message(e.message)
return undefined;
}
}
</script>
<PaginatedList bind:fetch={fetch} let:data={characters} let:pagination={pagination}>
<div slot="header">
<p>{pagination.item_count.toLocaleString()} characters in the database</p>
</div>
<table slot="body" class="table table-dark table-responsive">
<thead>
<td>ID</td>
<td>Name</td>
<td>Last Played</td>
<td>Created</td>
</thead>
<tbody>
{#each characters as char, i}
<tr>
<td>#{char.id}</td>
<td><CharacterLink character={char} /></td>
<td>{moment(char.last_login).fromNow()}</td>
<td>{moment(char.created).fromNow()}</td>
</tr>
{/each}
</tbody>
</table>
</PaginatedList>

66
app/views/Home.svelte Normal file
View file

@ -0,0 +1,66 @@
<script>
import { onMount } from 'svelte';
import axios from 'axios'
import moment from 'moment'
import { loggedIn } from '../UserState'
import Alert from '../components/Alert'
import AccountLink from '../components/AccountLink'
import CharacterLink from '../components/CharacterLink'
import EmpireStats from '../components/EmpireStats'
export let ready;
let stats;
let alert;
function format_account(account) {
return `<a href="/user/${account.id}">${account.username}</a>`
}
onMount(async () => {
try {
const resp = await axios.get("/api/stats")
stats = resp.data;
alert.message("")
} catch (e) {
console.log(e)
alert.message("Failed to fetch stats from server")
}
ready = true
})
</script>
<svelte:head>
<title>PSForever</title>
</svelte:head>
<Alert bind:this={alert} />
{#if stats}
<div class="row">
<div class="col-8">
<h1>PSForever Beta Server</h1>
<p>
<strong>Server address:</strong> <code>play.psforever.net:51200</code> (<a href="https://docs.google.com/document/d/1ZMx1NUylVZCXJNRyhkuVWT0eUKSVYu0JXsU-y3f93BY/edit">Setup Instructions</a>)<br/>
<strong>PSForever accounts:</strong> {stats.accounts.toLocaleString()}<br/>
<strong>Server characters:</strong> {stats.characters.toLocaleString()}<br/>
<strong>Last character created:</strong> <CharacterLink character={stats.last.character} /> (<span title={moment(stats.last.character.created).format(`MMMM Do YYYY, h:mm:ss a`)}>{moment(stats.last.character.created).fromNow()}</span>)</p>
</div>
<div class="col-4">
<EmpireStats stats={stats.empires} />
</div>
</div>
{#if !$loggedIn}
<div class="row">
<div class="col">
<a class="btn btn-primary" href="/login" role="button">Login</a>
<a class="btn btn-primary" href="/register" role="button">Create Account</a>
</div>
</div>
{/if}
{/if}

91
app/views/Login.svelte Normal file
View file

@ -0,0 +1,91 @@
<script>
import { onMount } from 'svelte';
import axios from 'axios'
import page from 'page';
import Alert from '../components/Alert'
import { fade } from 'svelte/transition';
import { formToJSON } from '../util/form.js'
import { get_initial_state } from '../UserState.js';
export let ready = false;
const redirect = (new URL(window.location.href)).searchParams.get('redirect')
let loginAttempts = 0;
let alert;
onMount(async () => {
// always check the initial state to see if we need to display this or not
if (await get_initial_state()) {
if (redirect)
page.redirect(redirect)
else
page.redirect("/")
} else {
ready = true;
}
});
async function submitLogin(e) {
try {
const resp = await axios.post("/api/login", formToJSON(event.target))
if (await get_initial_state()) {
if (redirect)
page.redirect(redirect)
else
page.redirect("/")
alert.message()
loginAttempts = 0;
} else {
alert.message("Unknown login failure")
}
} catch (e) {
if (e.response) {
if (e.response.status >= 500) {
alert.message("Unknown server error. Contact an administrator if this persists.")
} else if (e.response.status === 403) {
loginAttempts++;
const badpass = "Bad username and/or password."
if (loginAttempts >= 5) {
alert.message(badpass, `If you cannot remember your credentials, <a href="/recovery">reset them</a>. Otherwise you risk being locked out.`)
} else {
alert.message(badpass)
}
} else {
alert.message("Unknown server error status");
}
} else if (e.request) {
alert.message("Unknown server error. Contact an administrator if this persists.")
} else {
alert.message("Unknown request error: " + e.message)
}
}
}
</script>
<svelte:head>
<title>PSForever - Login</title>
</svelte:head>
<main>
<h1>Login to PSForever</h1>
<Alert bind:this={alert} />
<form name="login" class="form-group" on:submit|preventDefault={submitLogin}>
<div class="form-group">
<label for="inputUsername">Username</label>
<input class="form-control" id="inputUsername" placeholder="Username" name="username" required>
</div>
<div class="form-group">
<label for="inputPassword">Password</label>
<input class="form-control" type="password" id="inputPassword" placeholder="Password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<p><a href="/recovery">Forgot username/password?</a></p>
</main>

82
app/views/Profile.svelte Normal file
View file

@ -0,0 +1,82 @@
<script>
import { onMount } from 'svelte';
import axios from 'axios'
import page from 'page'
import moment from 'moment'
import CharacterLink from '../components/CharacterLink'
import LoginList from '../components/LoginList'
export let ready;
ready = false;
let username;
let characters = [];
let createDate;
let isAdmin;
let email;
let account;
onMount(async () => {
try {
const resp = await axios.get("/api/user/profile")
account = resp.data;
username = resp.data.name;
characters = resp.data.characters;
createDate = moment(resp.data.account_created).format('MMMM Do YYYY, h:mm:ss a')
+ " (" + moment(resp.data.account_created).fromNow() + ")";
isAdmin = resp.data.admin;
email = resp.data.email;
ready = true
} catch (e) {
if (e.response && e.response.status == 403) {
page("/login?redirect=/profile")
}
}
});
</script>
<svelte:head>
<title>PSForever - Profile</title>
</svelte:head>
<h1>Your Account</h1>
<form>
{#if isAdmin}
<strong class="color-red">You are a GM.</strong>
{/if}
<div class="form-group row">
<label for="staticUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="staticUsername" bind:value={username}>
</div>
</div>
<div class="form-group row">
<label for="staticEmail" class="col-sm-2 col-form-label">Email</label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="staticEmail" bind:value={email}>
</div>
</div>
<div class="form-group row">
<label for="staticAccountCreated" class="col-sm-2 col-form-label">Account Created</label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="staticAccountCreated" bind:value={createDate}>
</div>
</div>
</form>
<h2>Characters</h2>
{#if characters.length > 1}
<div class="row">
{#each characters as char, i}
<div class="col-md-4 col-12"><CharacterLink character={char} /></div>
{/each}
</div>
{:else}
<p>You have no characters</p>
{/if}
<h2>Logins</h2>
{#if account}
<LoginList account_id={account.id} />
{/if}

109
app/views/Register.svelte Normal file
View file

@ -0,0 +1,109 @@
<script>
import axios from 'axios'
import page from 'page';
import Alert from '../components/Alert'
import { formToJSON } from '../util/form.js'
import { get_initial_state } from '../UserState.js';
let alert;
let validated = false;
let email, password; // for validation
function validatePassword(e) {
// cant use cpassword here because svelte has not propagated the bound value yet
if (password !== e.target.value)
e.target.setCustomValidity("Passwords do not match")
else
e.target.setCustomValidity("")
}
function validateEmail(e) {
if (email !== e.target.value)
e.target.setCustomValidity("Emails do not match")
else
e.target.setCustomValidity("")
}
async function submitLogin(e) {
const data = formToJSON(e.target);
if (e.target.checkValidity() === false) {
validated = true
return
}
validated = true
try {
delete data.cpassword
delete data.cemail
const resp = await axios.post("/api/register", data);
if (await get_initial_state()) {
page.redirect("/")
} else {
alert.message("Unknown login failure")
}
} catch (e) {
if (e.response) {
if (e.response.status >= 500) {
alert.message("Unknown server error. Contact an administrator if this persists.")
} else if (e.response.status === 400) {
alert.message(e.response.data.message)
} else {
alert.message("Unknown server error status");
}
} else if (e.request) {
alert.message("Unknown server error. Contact an administrator if this persists.")
} else {
alert.message("Unknown request error: " + e.message)
}
}
}
</script>
<svelte:head>
<title>PSForever - Register</title>
</svelte:head>
<main>
<h1>Register for PSForever</h1>
<Alert bind:this={alert} />
<form name="login" class:was-validated={validated} class="form-group needs-validation" novalidate on:submit|preventDefault={submitLogin}>
<div class="form-group">
<label for="inputUsername">Username</label>
<input class="form-control" id="inputUsername" placeholder="Username" name="username" minlength=3 pattern={String.raw`[A-Za-z0-9]{3,}`} required>
<small id="emailHelp" class="form-text text-muted">This is used to login via the game client, launcher, and web interface.</small>
<div class="invalid-feedback">
Usernames must be at least 3 characters long and not contain special characters or spaces.
</div>
</div>
<div class="form-row">
<div class="form-group col">
<label for="inputPassword">Password</label>
<input bind:value={password} class="form-control" type="password" id="inputPassword" placeholder="Password" name="password" required>
</div>
<div class="form-group col">
<label for="inputCPassword">Confim Password</label>
<input on:input={validatePassword} on:change={validatePassword} class="form-control" type="password" id="inputCPassword" placeholder="Confirm Password" name="cpassword" required>
<div class="invalid-feedback">Passwords must match.</div>
</div>
</div>
<div class="form-row">
<div class="form-group col">
<label for="inputEmail">Email</label>
<input bind:value={email} class="form-control" type="email" id="inputEmail" placeholder="Email" name="email" required>
<small id="emailHelp" class="form-text text-muted">Emails are used to help confirm and recover accounts.</small>
<div class="invalid-feedback">Please provide a valid email address.</div>
</div>
<div class="form-group col">
<label for="inputCEmail">Confirm Email</label>
<input on:input={validateEmail} on:change={validateEmail} class="form-control" type="email" id="inputCEmail" placeholder="Confirm Email" name="cemail" required>
<div class="invalid-feedback">Email addresses must match.</div>
</div>
</div>
<button type="submit">Join the fight!</button>
</form>
</main>

4
app/views/User.svelte Normal file
View file

@ -0,0 +1,4 @@
<script>
</script>

150
app/views/UserList.svelte Normal file
View file

@ -0,0 +1,150 @@
<script>
import { onMount } from 'svelte'
import axios from 'axios'
import AccountLink from '../components/AccountLink'
import PaginatedList from '../components/PaginatedList'
import Alert from '../components/Alert'
import moment from 'moment'
import jq from 'jquery'
export let appAlert
let modalAlert, userList;
onMount(() => setup_actions());
async function fetch(page) {
try {
const resp = await axios.get("/api/users?page="+page)
appAlert.message("")
return [resp.data.users, resp.data.page];
} catch (e) {
appAlert.message(e.message)
return undefined;
}
}
function setup_actions() {
const modal = jq('#actionModal');
modal.on('hide.bs.modal', () => modalAlert.message(""));
modal.on('show.bs.modal', (event) => {
const button = jq(event.relatedTarget) // Button that triggered the modal
const username = button.data('account-name')
const account_id = button.data('account-id')
const action_type = button.data('action')
const action_name = button.text();
modal.find('.modal-title').text("Confirm " + action_name)
modal.find('.modal-body p').text("Are you sure you want to perform this action on \'" + username + "\'?")
const submit = modal.find('.modal-footer .btn-primary')
submit.text(action_name)
// remove ALL previous click handlers
submit.off()
submit.click(async (event) => {
submit.addClass("disabled")
try {
await axios.post("/api/user/"+ account_id + "/" + action_type)
await userList.refresh()
modal.modal('hide')
} catch (e) {
modalAlert.message(e.message)
} finally {
submit.removeClass("disabled")
}
});
})
}
</script>
<PaginatedList bind:this={userList} bind:fetch={fetch} let:data={users} let:pagination={pagination}>
<div slot="header">
<p>{pagination.item_count.toLocaleString()} users in the database</p>
</div>
<table slot="body" class="table table-dark table-responsive">
<thead>
<td>ID</td>
<td>Username</td>
<td>User Created</td>
<td>Last Login</td>
<td>Actions</td>
</thead>
<tbody>
{#each users as user, i}
<tr>
<td>#{user.id}</td>
<td><AccountLink account={user} /></td>
<td>{moment(user.created).fromNow()}</td>
<td>{#if user.last_login.time}
{moment(user.last_login.time).fromNow()}<br/>
<code>{user.last_login.hostname} - {user.last_login.ip}</code>
{:else}
Never logged in
{/if}
</td>
<td>
{#if user.inactive}
<button type="button"
class="btn btn-warning btn-sm"
data-action="unban"
data-account-id={user.id}
data-account-name={user.name}
data-toggle="modal"
data-target="#actionModal">Unban</button>
{:else}
<button type="button"
class="btn btn-danger btn-sm"
data-action="ban"
data-account-id={user.id}
data-account-name={user.name}
data-toggle="modal"
data-target="#actionModal">Ban</button>
{#if user.admin}
<button type="button"
class="btn btn-warning btn-sm"
data-action="remove_gm"
data-account-id={user.id}
data-account-name={user.name}
data-toggle="modal"
data-target="#actionModal">Remove GM</button>
{:else}
<button type="button"
class="btn btn-success btn-sm"
data-action="add_gm"
data-account-id={user.id}
data-account-name={user.name}
data-toggle="modal"
data-target="#actionModal">Make GM</button>
{/if}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</PaginatedList>
<div class="modal fade" id="actionModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Perform Action</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<Alert bind:this={modalAlert} />
<p>Are you sure?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
<button type="button" class="btn btn-primary">Yes</button>
</div>
</div>
</div>
</div>