Move to Poetry for deps and packaging

setup.py has been removed, and Poetry is now used to package for
install. This fixed an issue i had with uploading to PyPI, so one can
now run 'pip install jellyfin-accounts' to install.
This commit is contained in:
2020-06-16 20:07:47 +01:00
parent f4f18d41ea
commit bfbaca4094
37 changed files with 787 additions and 357 deletions

275
jellyfin_accounts/__init__.py Normal file → Executable file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
__version__ = "0.2"
import secrets
import configparser
import shutil
import argparse
import logging
import threading
import signal
import sys
import json
from pathlib import Path
from flask import Flask, g
from jellyfin_accounts.data_store import JSONStorage
parser = argparse.ArgumentParser(description="jellyfin-accounts")
parser.add_argument("-c", "--config",
help="specifies path to configuration file.")
parser.add_argument("-d", "--data",
help=("specifies directory to store data in. " +
"defaults to ~/.jf-accounts."))
parser.add_argument("--host",
help="address to host web ui on.")
parser.add_argument("-p", "--port",
help="port to host web ui on.")
parser.add_argument("-g", "--get_defaults",
help=("tool to grab a JF users " +
"policy (access, perms, etc.) and " +
"homescreen layout and " +
"output it as json to be used as a user template."),
action='store_true')
args, leftovers = parser.parse_known_args()
if args.data is not None:
data_dir = Path(args.data)
else:
data_dir = Path.home() / '.jf-accounts'
local_dir = (Path(__file__).parent / 'data').resolve()
first_run = False
if data_dir.exists() is False or (data_dir / 'config.ini').exists() is False:
if not data_dir.exists():
Path.mkdir(data_dir)
print(f'Config dir not found, so created at {str(data_dir)}')
if args.config is None:
config_path = data_dir / 'config.ini'
shutil.copy(str(local_dir / 'config-default.ini'),
str(config_path))
print("Setup through the web UI, or quit and edit the configuration manually.")
first_run = True
else:
config_path = Path(args.config)
print(f'config.ini can be found at {str(config_path)}')
else:
config_path = data_dir / 'config.ini'
config = configparser.RawConfigParser()
config.read(config_path)
def create_log(name):
log = logging.getLogger(name)
handler = logging.StreamHandler(sys.stdout)
if config.getboolean('ui', 'debug'):
log.setLevel(logging.DEBUG)
handler.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
handler.setLevel(logging.INFO)
fmt = ' %(name)s - %(levelname)s - %(message)s'
format = logging.Formatter(fmt)
handler.setFormatter(format)
log.addHandler(handler)
log.propagate = False
return log
log = create_log('main')
web_log = create_log('waitress')
if not first_run:
email_log = create_log('emails')
auth_log = create_log('auth')
if args.host is not None:
log.debug(f'Using specified host {args.host}')
config['ui']['host'] = args.host
if args.port is not None:
log.debug(f'Using specified port {args.port}')
config['ui']['port'] = args.port
for key in config['files']:
if config['files'][key] == '':
if key != 'custom_css':
log.debug(f'Using default {key}')
config['files'][key] = str(data_dir / (key + '.json'))
for key in ['user_configuration', 'user_displayprefs']:
if key not in config['files']:
log.debug(f'Using default {key}')
config['files'][key] = str(data_dir / (key + '.json'))
with open(config['files']['invites'], 'r') as f:
temp_invites = json.load(f)
if 'invites' in temp_invites:
new_invites = {}
log.info('Converting invites.json to new format, temporary.')
for el in temp_invites['invites']:
i = {'valid_till': el['valid_till']}
if 'email' in el:
i['email'] = el['email']
new_invites[el['code']] = i
with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(new_invites, indent=4, default=str))
data_store = JSONStorage(config['files']['emails'],
config['files']['invites'],
config['files']['user_template'],
config['files']['user_displayprefs'],
config['files']['user_configuration'])
def default_css():
css = {}
css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
css['crossorigin'] = "anonymous"
return css
css = {}
css = default_css()
if 'custom_css' in config['files']:
if config['files']['custom_css'] != '':
try:
shutil.copy(config['files']['custom_css'],
(local_dir / 'static' / 'bootstrap.css'))
log.debug('Loaded custom CSS')
css['href'] = '/bootstrap.css'
css['integrity'] = ''
css['crossorigin'] = ''
except FileNotFoundError:
log.error(f'Custom CSS {config["files"]["custom_css"]} not found, using default.')
if ('email_html' not in config['password_resets'] or
config['password_resets']['email_html'] == ''):
log.debug('Using default password reset email HTML template')
config['password_resets']['email_html'] = str(local_dir / 'email.html')
if ('email_text' not in config['password_resets'] or
config['password_resets']['email_text'] == ''):
log.debug('Using default password reset email plaintext template')
config['password_resets']['email_text'] = str(local_dir / 'email.txt')
if ('email_html' not in config['invite_emails'] or
config['invite_emails']['email_html'] == ''):
log.debug('Using default invite email HTML template')
config['invite_emails']['email_html'] = str(local_dir /
'invite-email.html')
if ('email_text' not in config['invite_emails'] or
config['invite_emails']['email_text'] == ''):
log.debug('Using default invite email plaintext template')
config['invite_emails']['email_text'] = str(local_dir /
'invite-email.txt')
if ('public_server' not in config['jellyfin'] or
config['jellyfin']['public_server'] == ''):
config['jellyfin']['public_server'] = config['jellyfin']['server']
def main():
if args.get_defaults:
import json
from jellyfin_accounts.jf_api import Jellyfin
jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['client'],
config['jellyfin']['version'],
config['jellyfin']['device'],
config['jellyfin']['device_id'])
print("NOTE: This can now be done through the web ui.")
print("""
This tool lets you grab various settings from a user,
so that they can be applied every time a new account is
created. """)
print("Step 1: User Policy.")
print("""
A user policy stores a users permissions (e.g access rights and
most of the other settings in the 'Profile' and 'Access' tabs
of a user). """)
success = False
msg = "Get public users only or all users? (requires auth) [public/all]: "
public = False
while not success:
choice = input(msg)
if choice == 'public':
public = True
print("Make sure the user is publicly visible!")
success = True
elif choice == 'all':
jf.authenticate(config['jellyfin']['username'],
config['jellyfin']['password'])
public = False
success = True
users = jf.getUsers(public=public)
for index, user in enumerate(users):
print(f'{index+1}) {user["Name"]}')
success = False
while not success:
try:
user_index = int(input(">: "))-1
policy = users[user_index]['Policy']
success = True
except (ValueError, IndexError):
pass
data_store.user_template = policy
print(f'Policy written to "{config["files"]["user_template"]}".')
print('In future, this policy will be copied to all new users.')
print('Step 2: Homescreen Layout')
print("""
You may want to customize the default layout of a new user's
home screen. These settings can be applied to an account through
the 'Home' section in a user's settings. """)
success = False
while not success:
choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
if choice.lower() == 'y':
user_id = users[user_index]['Id']
configuration = users[user_index]['Configuration']
display_prefs = jf.getDisplayPreferences(user_id)
data_store.user_configuration = configuration
print(f'Configuration written to "{config["files"]["user_configuration"]}".')
data_store.user_displayprefs = display_prefs
print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".')
success = True
elif choice.lower() == 'n':
success = True
else:
def signal_handler(sig, frame):
print('Quitting...')
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
global app
app = Flask(__name__, root_path=str(local_dir))
app.config['DEBUG'] = config.getboolean('ui', 'debug')
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
from waitress import serve
if first_run:
import jellyfin_accounts.setup
host = config['ui']['host']
port = config['ui']['port']
log.info('Starting web UI for first run setup...')
serve(app,
host=host,
port=port)
else:
import jellyfin_accounts.web_api
import jellyfin_accounts.web
host = config['ui']['host']
port = config['ui']['port']
log.info(f'Starting web UI on {host}:{port}')
if config.getboolean('password_resets', 'enabled'):
def start_pwr():
import jellyfin_accounts.pw_reset
jellyfin_accounts.pw_reset.start()
pwr = threading.Thread(target=start_pwr, daemon=True)
log.info('Starting email thread')
pwr.start()
serve(app,
host=host,
port=int(port))

View File

@@ -0,0 +1,116 @@
[jellyfin]
; It is reccommended to create a limited admin account for this program.
username = username
password = password
; Jellyfin server address. Can be public, or local for security purposes.
server = http://jellyfin.local:8096
; Publicly accessible Jellyfin address, used on invite form.
; Leave blank to use the same address as above.
public_server = https://jellyf.in:443
client = jf-accounts
version = 0.1
device = jf-accounts
device_id = jf-accounts-0.1
[ui]
; Set 0.0.0.0 to run localhost
host = 0.0.0.0
port = 8056
; Enable this to use Jellyfin users instead of the below username and pw.
jellyfin_login = true
; Allows only admin users on Jellyfin to access admin page.
admin_only = true
; Username to use on admin page... (leave blank if using jellyfin_login)
username = your username
; ..and its corresponding password (leave blank if using jellyfin_login)
password = your password
debug = false
; Displayed at the bottom of all pages except admin
contact_message = Need help? contact me.
; Displayed at top of form page.
help_message = Enter your details to create an account.
; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin.
[password_validation]
; Enables password validation.
enabled = true
; Min. password length
min_length = 8
; Min. number of uppercase characters
upper = 1
; Min. number of lowercase characters
lower = 0
; Min. number of numbers
number = 1
; Min. number of special characters
special = 0
[email]
; Leave this whole section if you aren't using any email-related features.
use_24h = true
; Date format follows datetime's strftime.
date_format = %d/%m/%y
; Displayed at bottom of emails
message = Need help? contact me.
; Mail methods: mailgun, smtp
method = smtp
; Address to send from
address = jellyfin@jellyf.in
; The name of the sender
from = Jellyfin
[password_resets]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
; Subject of emails
subject = Password Reset - Jellyfin
[invite_emails]
; If enabled, allows one to send an invite directly to an email address.
enabled = true
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
subject = Invite - Jellyfin
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
url_base = http://accounts.jellyf.in:8056/invite
[mailgun]
api_url = https://api.mailgun.net...
api_key = your api key
[smtp]
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
encryption = starttls
server = smtp.jellyf.in
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
port = 465
password = smtp password
[files]
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
; Path to store valid invites.
invites =
; Path to store emails addresses in JSON
emails =
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
user_template =
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
user_configuration =
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
user_displayprefs =
; Path to custom bootstrap.css
custom_css =

View File

@@ -0,0 +1,242 @@
<!-- FILE: email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<p style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
{{ pin }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</mj-text>
<mj-button>{{ pin }}</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@@ -0,0 +1,10 @@
Hi {{ username }},
Someone has recently requests a password reset on Jellyfin.
If this was you, enter the below pin into the prompt.
This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}.
If this wasn't you, please ignore this email.
PIN: {{ pin }}
{{ message }}

View File

@@ -0,0 +1,239 @@
<!-- FILE: invite-email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<a href="{{ invite_link }}" style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Setup your account </a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</mj-text>
<mj-button href="{{ invite_link }}">Setup your account</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@@ -0,0 +1,8 @@
Hi,
You've been invited to Jellyfin.
To join, follow the below link.
This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.
{{ invite_link }}
{{ message }}

View File

@@ -0,0 +1,451 @@
function parseInvite(invite, empty = false) {
if (empty === true) {
return ["None", "", "1"]
} else {
var i = ["", "", "0", invite['email']];
i[0] = invite['code'];
if (invite['hours'] == 0) {
i[1] = invite['minutes'] + 'm';
} else if (invite['minutes'] == 0) {
i[1] = invite['hours'] + 'h';
} else {
i[1] = invite['hours'] + 'h ' + invite['minutes'] + 'm';
}
i[1] = "Expires in " + i[1] + " ";
return i
}
}
function addItem(invite) {
var links = document.getElementById('invites');
var listItem = document.createElement('li');
listItem.id = invite[0]
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
var listCode = document.createElement('div');
listCode.classList.add('d-flex', 'align-items-center', 'text-monospace');
var codeLink = document.createElement('a');
codeLink.setAttribute('style', 'margin-right: 2%;');
codeLink.appendChild(document.createTextNode(invite[0].replace(/-/g, '')));
listCode.appendChild(codeLink);
listItem.appendChild(listCode);
var listRight = document.createElement('div');
listText = document.createElement('span');
listText.id = invite[0] + '_expiry'
listText.appendChild(document.createTextNode(invite[1]));
listRight.appendChild(listText);
if (invite[2] == 0) {
var inviteCode = window.location.href + 'invite/' + invite[0];
codeLink.href = inviteCode;
// listCode.appendChild(document.createTextNode(" "));
var codeCopy = document.createElement('i');
codeCopy.onclick = function(){toClipboard(inviteCode)};
codeCopy.classList.add('fa', 'fa-clipboard');
listCode.appendChild(codeCopy);
if (typeof(invite[3]) != 'undefined') {
var sentTo = document.createElement('span');
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
if (invite[3].includes('Failed to send to')) {
sentTo.appendChild(document.createTextNode(invite[3]));
} else {
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
}
listCode.appendChild(sentTo);
};
var listDelete = document.createElement('button');
listDelete.onclick = function(){deleteInvite(invite[0])};
listDelete.classList.add('btn', 'btn-outline-danger');
listDelete.appendChild(document.createTextNode('Delete'));
listRight.appendChild(listDelete);
};
listItem.appendChild(listRight);
links.appendChild(listItem);
};
function updateInvite(invite) {
var expiry = document.getElementById(invite[0] + '_expiry');
expiry.textContent = invite[1];
}
function removeInvite(code) {
var item = document.getElementById(code);
item.parentNode.removeChild(item);
}
function generateInvites(empty = false) {
// document.getElementById('invites').textContent = '';
if (empty === false) {
$.ajax('/getInvites', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
data: { get_param: 'value' },
complete: function(response) {
var data = JSON.parse(response['responseText']);
if (data['invites'].length == 0) {
document.getElementById('invites').textContent = '';
addItem(parseInvite([], true));
} else {
data['invites'].forEach(function(invite) {
var match = false;
var items = document.getElementById('invites').children;
for (var i = 0; i < items.length; i++) {
if (items[i].id == invite['code']) {
match = true;
updateInvite(parseInvite(invite));
};
};
if (match == false) {
addItem(parseInvite(invite));
};
});
var items = document.getElementById('invites').children;
for (var i = 0; i < items.length; i++) {
var exists = false;
data['invites'].forEach(function(invite) {
if (items[i].id == invite['code']) {
exists = true;
}
});
if (exists == false) {
removeInvite(items[i].id);
}
};
};
}
});
} else if (empty === true) {
document.getElementById('invites').textContent = '';
addItem(parseInvite([], true));
};
};
function deleteInvite(code) {
var send = JSON.stringify({ "code": code });
$.ajax('/deleteInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { generateInvites(); },
});
};
function addOptions(le, sel) {
for (v = 0; v <= le; v++) {
var opt = document.createElement('option');
opt.appendChild(document.createTextNode(v))
opt.value = v
sel.appendChild(opt)
}
};
function toClipboard(str) {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
$("form#inviteForm").submit(function() {
var button = document.getElementById('generateSubmit');
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
var send_object = $("form#inviteForm").serializeObject();
if (document.getElementById('send_to_address') != null) {
if (document.getElementById('send_to_address_enabled').checked) {
send_object['email'] = document.getElementById('send_to_address').value;
}
}
var send = JSON.stringify(send_object);
$.ajax('/generateInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() {
button.textContent = 'Generate';
button.disabled = false;
generateInvites();
},
});
return false;
});
$("form#loginForm").submit(function() {
window.token = "";
var details = $("form#loginForm").serializeObject();
var errorArea = document.getElementById('loginErrorArea');
errorArea.textContent = '';
var button = document.getElementById('loginSubmit');
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('/getToken', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
},
data: { get_param: 'value' },
complete: function(data) {
if (data['status'] == 401) {
button.disabled = false;
button.textContent = 'Login';
var wrongPassword = document.createElement('div');
wrongPassword.classList.add('alert', 'alert-danger');
wrongPassword.setAttribute('role', 'alert');
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
errorArea.appendChild(wrongPassword);
} else {
window.token = JSON.parse(data['responseText'])['token'];
generateInvites();
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
var hour = document.getElementById('hours');
addOptions(24, hour);
hour.selected = "0";
var minutes = document.getElementById('minutes');
addOptions(59, minutes);
minutes.selected = "30";
$('#login').modal('hide');
}
}
});
return false;
});
document.getElementById('openSettings').onclick = function () {
$('#settingsMenu').modal('show');
}
document.getElementById('openDefaultsWizard').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('getUsers', {
type : 'GET',
dataType : 'json',
contentType : 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
complete : function(data) {
if (data['status'] == 200) {
var radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
if (document.getElementById('setDefaultUser')) {
document.getElementById('setDefaultUser').remove();
};
var users = data['responseJSON']['users'];
for (var i = 0; i < users.length; i++) {
var user = users[i]
var radio = document.createElement('div');
radio.classList.add('radio');
if (i == 0) {
var checked = 'checked';
} else {
var checked = '';
};
radio.innerHTML =
'<label><input type="radio" name="defaultRadios" id="default_' +
user['name'] + '" style="margin-right: 1rem;"' + checked + '>' +
user['name'] + '</label>';
radioList.appendChild(radio);
}
var button = document.getElementById('openDefaultsWizard');
button.disabled = false;
button.innerHTML = 'Set new account defaults';
var submitButton = document.getElementById('storeDefaults');
submitButton.disabled = false;
submitButton.textContent = 'Submit';
if (submitButton.classList.contains('btn-success')) {
submitButton.classList.remove('btn-success');
submitButton.classList.add('btn-primary');
} else if (submitButton.classList.contains('btn-danger')) {
submitButton.classList.remove('btn-danger');
submitButton.classList.add('btn-primary');
}
$('#userDefaults').modal('show');
}
}
});
};
document.getElementById('storeDefaults').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
var button = document.getElementById('storeDefaults');
var radios = document.getElementsByName('defaultRadios');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) {
var data = {'username':radios[i].id.slice(8), 'homescreen':false};
if (document.getElementById('storeDefaultHomescreen').checked) {
data['homescreen'] = true;
}
$.ajax('/setDefaults', {
data : JSON.stringify(data),
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() {
button.textContent = 'Success';
if (button.classList.contains('btn-danger')) {
button.classList.remove('btn-danger');
} else if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
};
button.classList.add('btn-success');
button.disabled = false;
setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
},
error: function() {
button.textContent = 'Failed';
button.classList.remove('btn-primary');
button.classList.add('btn-danger');
setTimeout(function(){
var button = document.getElementById('storeDefaults');
button.textContent = 'Submit';
button.classList.remove('btn-danger');
button.classList.add('btn-primary');
button.disabled = false;
}, 1000);
}
});
}
}
};
document.getElementById('openUsers').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('/getUsers', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
data: { get_param: 'value' },
complete : function(data) {
if (data['status'] == 200) {
var list = document.getElementById('userList');
list.textContent = '';
if (document.getElementById('saveUsers')) {
document.getElementById('saveUsers').remove();
};
var users = data['responseJSON']['users'];
for (var i = 0; i < users.length; i++) {
var user = users[i]
var entry = document.createElement('p');
entry.id = 'user_' + user['name'];
entry.appendChild(document.createTextNode(user['name']));
var address = document.createElement('span');
address.setAttribute('style', 'margin-left: 2%; margin-right: 2%; color: grey;');
address.classList.add('addressText');
address.id = 'address_' + user['email'];
if (typeof(user['email']) != 'undefined') {
address.appendChild(document.createTextNode(user['email']));
};
var editButton = document.createElement('i');
editButton.classList.add('fa', 'fa-edit');
editButton.onclick = function() {
this.classList.remove('fa', 'fa-edit');
var input = document.createElement('input');
input.setAttribute('type', 'email');
input.setAttribute('style', 'margin-left: 2%; color: grey;');
var addressElement = this.parentNode.getElementsByClassName('addressText')[0];
if (addressElement.textContent != '') {
input.value = addressElement.textContent;
} else {
input.placeholder = 'Email Address';
};
this.parentNode.replaceChild(input, addressElement);
if (document.getElementById('saveUsers') == null) {
var footer = document.getElementById('userFooter')
var saveUsers = document.createElement('input');
saveUsers.classList.add('btn', 'btn-primary');
saveUsers.setAttribute('type', 'button');
saveUsers.value = 'Save Changes';
saveUsers.id = 'saveUsers';
saveUsers.onclick = function() {
var send = {}
var entries = document.getElementById('userList').children;
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
var name = entry.id.replace(/user_/g, '')
var address = entry.getElementsByTagName('input')[0].value;
send[name] = address
};
};
send = JSON.stringify(send);
$.ajax('/modifyUsers', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { $('#users').modal('hide'); },
});
};
footer.appendChild(saveUsers);
};
};
entry.appendChild(address);
entry.appendChild(editButton);
list.appendChild(entry);
};
var button = document.getElementById('openUsers');
button.disabled = false;
button.innerHTML = 'Users <i class="fa fa-user"></i>';
$('#users').modal('show');
};
}
});
};
generateInvites(empty = true);
$("#login").modal('show');

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#603cba</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="682.667" height="682.667" viewBox="0 0 512.000000 512.000000"><path d="M246 23.7c-13.9 2-30 8.6-40.6 16.6-20.2 15.4-32.5 40-32.5 65.5 0 38.8 24.2 70.7 61.9 81.4 11.1 3.2 31.4 3.1 42.7-.1 47.8-13.4 73-62.2 56.5-109.5-7.8-22.5-27.9-42.1-51.5-50-10.6-3.6-26.4-5.3-36.5-3.9zM235.5 205.6c-37.7 6.8-73.7 27.7-106.2 61.8-31.6 33.1-54.2 73.1-62.4 110.8-3 13.3-3.2 35.7-.5 45.8 5.6 21.2 20.1 36.8 43.4 46.9 9.2 3.9 21.4 7.8 30.7 9.6 10 1.9 26.1 4.5 32.5 5.1 4.1.4 8.9.8 10.5 1 25.3 2.5 85.7 2.4 107-.1 1.7-.2 5.7-.7 9-1 6.8-.7 11.2-1.2 17-1.9 34.7-4.4 70.3-13.2 90.8-22.4 17.8-8.1 30.7-18.6 35.4-28.8 5.8-12.6 6.6-36.1 1.9-56.4-5-22-16.8-48.3-31.5-70.5-33.6-50.8-80.8-87-128.1-98.2-13.3-3.1-37.1-3.9-49.5-1.7z"/></svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@@ -0,0 +1,234 @@
function checkAuthRadio() {
if (document.getElementById('manualAuthRadio').checked) {
document.getElementById('adminOnlyArea').style.display = 'none';
document.getElementById('manualAuthArea').style.display = '';
} else {
document.getElementById('manualAuthArea').style.display = 'none';
document.getElementById('adminOnlyArea').style.display = '';
};
};
var authRadios = ['manualAuthRadio', 'jfAuthRadio'];
for (var i = 0; i < authRadios.length; i++) {
document.getElementById(authRadios[i]).addEventListener('change', function() {
checkAuthRadio();
});
};
function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7';
if (document.getElementById('emailSMTPRadio').checked) {
document.getElementById('emailSMTPArea').style.display = '';
document.getElementById('emailMailgunArea').style.display = 'none';
} else if (document.getElementById('emailMailgunRadio').checked) {
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = '';
} else if (document.getElementById('emailDisabledRadio').checked) {
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('emailNextButton').href = '#page-8';
document.getElementById('valBackButton').href = '#page-4';
};
};
var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio'];
for (var i = 0; i < emailRadios.length; i++) {
document.getElementById(emailRadios[i]).addEventListener('change', function() {
checkEmailRadio();
});
};
function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) {
label.textContent = 'Use SSL/TLS';
} else {
label.textContent = 'Use STARTTLS';
};
};
document.getElementById('emailSSL_TLS').addEventListener('change', function() {
checkSSL();
});
function checkPwrEnabled() {
if (document.getElementById('pwrEnabled').checked) {
document.getElementById('pwrArea').style.display = '';
} else {
document.getElementById('pwrArea').style.display = 'none';
};
};
var pwrEnabled = document.getElementById('pwrEnabled');
pwrEnabled.addEventListener('change', function() {
checkPwrEnabled();
});
function checkInvEnabled() {
if (document.getElementById('invEnabled').checked) {
document.getElementById('invArea').style.display = '';
} else {
document.getElementById('invArea').style.display = 'none';
};
};
document.getElementById('invEnabled').addEventListener('change', function() {
checkInvEnabled();
});
function checkValEnabled() {
if (document.getElementById('valEnabled').checked) {
document.getElementById('valArea').style.display = '';
} else {
document.getElementById('valArea').style.display = 'none';
};
};
document.getElementById('valEnabled').addEventListener('change', function() {
checkValEnabled();
});
checkValEnabled();
checkInvEnabled();
checkSSL();
checkAuthRadio();
checkEmailRadio();
checkPwrEnabled();
var jfValid = false
document.getElementById('jfTestButton').onclick = function() {
var testButton = document.getElementById('jfTestButton');
var nextButton = document.getElementById('jfNextButton');
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var jfData = {};
jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value;
$.ajax('/testJF', {
type : 'POST',
dataType : 'json',
contentType : 'application/json',
data : JSON.stringify(jfData),
complete: function(response) {
testButton.disabled = false;
testButton.className = '';
var success = response['responseJSON']['success'];
if (success == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
}
}
});
};
document.getElementById('submitButton').onclick = function() {
var submitButton = document.getElementById('submitButton');
submitButton.disabled = true;
submitButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Submitting...';
var config = {};
config['jellyfin'] = {};
config['ui'] = {};
config['password_validation'] = {};
config['email'] = {};
config['password_resets'] = {};
config['invite_emails'] = {};
config['mailgun'] = {};
config['smtp'] = {};
// Page 2: Auth
if (document.getElementById('jfAuthRadio').checked) {
config['ui']['jellyfin_login'] = 'true';
if (document.getElementById('jfAuthAdminOnly').checked) {
config['ui']['admin_only'] = 'true';
} else {
config['ui']['admin_only'] = 'false'
};
} else {
config['ui']['username'] = document.getElementById('manualAuthUsername').value;
config['ui']['password'] = document.getElementById('manualAuthPassword').value;
};
// Page 3: Connect to jellyfin
config['jellyfin']['server'] = document.getElementById('jfHost').value;
config['jellyfin']['username'] = document.getElementById('jfUser').value;
config['jellyfin']['password'] = document.getElementById('jfPassword').value;
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)
if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false';
} else {
if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls';
} else {
config['smtp']['encryption'] = 'starttls';
};
config['email']['method'] = 'smtp';
config['smtp']['server'] = document.getElementById('emailSMTPServer').value;
config['smtp']['port'] = document.getElementById('emailSMTPPort').value;
config['smtp']['password'] = document.getElementById('emailSMTPPassword').value;
config['email']['address'] = document.getElementById('emailSMTPAddress').value;
} else {
config['email']['method'] = 'mailgun';
config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value;
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
config['email']['address'] = document.getElementById('emailMailgunAddress').value;
};
// Page 5: Email formatting
config['email']['from'] = document.getElementById('emailSender').value;
config['email']['date_format'] = document.getElementById('emailDateFormat').value;
if (document.getElementById('email24hTimeRadio').checked) {
config['email']['use_24h'] = 'true';
} else {
config['email']['use_24h'] = 'false';
};
config['email']['message'] = document.getElementById('emailMessage').value;
// Page 6: Password Resets
if (document.getElementById('pwrEnabled').checked) {
config['password_resets']['enabled'] = 'true';
config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value;
config['password_resets']['subject'] = document.getElementById('pwrSubject').value;
} else {
config['password_resets']['enabled'] = 'false';
};
// Page 7: Invite Emails
if (document.getElementById('invEnabled').checked) {
config['invite_emails']['enabled'] = 'true';
config['invite_emails']['url_base'] = document.getElementById('invURLBase').value;
config['invite_emails']['subject'] = document.getElementById('invSubject').value;
} else {
config['invite_emails']['enabled'] = 'false';
};
};
// Page 8: Password Validation
if (document.getElementById('valEnabled').checked) {
config['password_validation']['enabled'] = 'true';
config['password_validation']['min_length'] = document.getElementById('valLength').value;
config['password_validation']['upper'] = document.getElementById('valUpper').value;
config['password_validation']['lower'] = document.getElementById('valLower').value;
config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else {
config['password_validation']['enabled'] = 'false';
};
// Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value;
console.log(config);
$.ajax('/modifyConfig', {
type : 'POST',
dataType : 'json',
contentType : 'application/json',
data : JSON.stringify(config),
complete: function(response) {
submitButton.disabled = false;
submitButton.className = '';
submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success';
}
});
};

View File

@@ -0,0 +1,19 @@
{
"name": "jf-accounts",
"short_name": "jf-accounts",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<title>404</title>
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ contactMessage }}
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,207 @@
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.pageContainer {
margin: 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%;
}
.linkGroup {
/*margin: 20%;*/
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
/*margin: 20%;*/
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
/*margin: 20%;*/
margin-top: 5%;
color: grey;
}
.fa-clipboard {
color: grey;
}
.fa-clipboard:hover {
color: black;
}
</style>
<title>Admin</title>
</head>
<body>
<div class="modal fade" id="login" tabindex="-1" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginTitle">Login</h5>
</div>
<div class="modal-body" id="formBody">
<form action="#" method="POST" id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
</div>
</form>
<div id="loginErrorArea"></div>
</div>
<div class="modal-footer">
<button type="submit" id="loginSubmit" class="btn btn-primary" form="loginForm">Login</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="settingsMenu" tabindex="-1" role="dialog" aria-labelledby="settings menu" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="settingsTitle">Settings</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<button type="button" class="btn btn-secondary" id="openUsers">
Users <i class="fa fa-user"></i>
</button>
</li>
<li class="list-group-item">
<button type="button" class="btn btn-secondary" id="openDefaultsWizard">
New account defaults
</button>
</li>
</ul>
</div>
<div class="modal-footer" id="settingsFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="users" tabindex="-1" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="usersTitle">Users</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush" id="userList">
</ul>
</div>
<div class="modal-footer" id="userFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="userDefaults" tabindex="-1" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultsTitle">New user defaults</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Create an account and configure it to your liking, then choose it from below to store the settings as a template for all new users.</p>
<div id="defaultUserRadios"></div>
<div class="checkbox">
<label><input type="checkbox" value="" style="margin-right: 1rem;" id="storeDefaultHomescreen" checked>Store homescreen layout</label>
</div>
</div>
<div class="modal-footer" id="defaultsFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="storeDefaults">Submit</button>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<h1>
Accounts admin
</h1>
<button type="button" class="btn btn-secondary" id="openSettings">
Settings <i class="fa fa-cog"></i>
</button>
<div class="card bg-light mb-3 linkGroup">
<div class="card-header">Current Invites</div>
<ul class="list-group list-group-flush" id="invites">
</ul>
</div>
<div class="linkForm">
<div class="card bg-light mb-3">
<div class="card-header">Generate Invite</div>
<div class="card-body">
<form action="#" method="POST" id="inviteForm">
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control" id="minutes" name="minutes">
</select>
</div>
{% if email_enabled %}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{% endif %}
<button type="submit" id="generateSubmit" class="btn btn-primary">Generate</button>
</form>
</div>
</div>
</div>
<div class="contactBox">
<p>{{ contactMessage }}</p>
</div>
</div>
<script src="admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<style>
.pageContainer {
margin: 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
.contactBox {
color: grey;
}
#container {
margin-top: 5%;
margin-bottom: 5%;
}
</style>
<title>Create Jellyfin Account</title>
</head>
<body>
<div class="modal fade" id="successBox" tabindex="-1" role="dialog" aria-labelledby="successBox" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="successTitle">Success!</h5>
</div>
<div class="modal-body" id="successBody">
<p>{{ successMessage }}</p>
</div>
<div class="modal-footer">
<a href="{{ jfLink }}" class="btn btn-primary">Continue</a>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<h1>
Create Account
</h1>
<p>{{ helpMessage }}</p>
<p class="contactBox">{{ contactMessage }}</p>
<div class="container" id="container">
<div class="row" id="cardContainer">
<div class="col-sm" id="accountForm">
<div class="card bg-light mb-3">
<div class="card-header">Details</div>
<div class="card-body">
<form action="#" method="POST">
<div class="form-group">
<label for="inputEmail">Email</label>
<input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" value="{{ email }}" required>
</div>
<div class="form-group">
<label for="inputUsername">Username</label>
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="Username" required>
</div>
<div class="form-group">
<label for="inputPassword">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="Password" required>
</div>
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox">
<button type="submit" class="btn btn-outline-primary" id="submitButton">
<span id="createAccount">Create Account</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% if validate %}
<div class="col-sm" id="requirementBox">
<div class="card bg-light mb-3 requirementBox">
<div class="card-header">Password Requirements</div>
<div class="card-body">
<ul class="list-group">
{% for key, value in requirements.items() %}
<li id="{{ key }}" class="list-group-item list-group-item-danger">
<div> {{ value }}</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
var code = window.location.href.split('/').pop();
function toggleSpinner () {
var submitButton = document.getElementById('submitButton');
var oldSpan = document.getElementById('createAccount');
var newSpan = document.createElement('span');
newSpan.id = 'createAccount';
if (document.getElementById('createAccountSpinner')) {
newSpan.appendChild(document.createTextNode('Create Account'));
submitButton.disabled = false;
} else {
var spinner = document.createElement('span');
spinner.id = 'createAccountSpinner';
spinner.classList.add('spinner-border', 'spinner-border-sm');
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
var text = document.createTextNode(' Creating...');
newSpan.appendChild(spinner);
newSpan.appendChild(text);
submitButton.disabled = true;
}
submitButton.replaceChild(newSpan, oldSpan);
};
$("form").submit(function() {
toggleSpinner();
var send = $("form").serializeObject();
send['code'] = code;
send = JSON.stringify(send);
$.ajax('/newUser', {
data : send,
contentType : 'application/json',
type : 'POST',
crossDomain : true,
complete : function(response){
toggleSpinner();
var data = response['responseJSON'];
if ('error' in data) {
var text = document.createTextNode(data['error']);
// <div class="alert alert-danger" id="errorBox"></div>
var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', '');
error.appendChild(text);
document.getElementById('errorBox').appendChild(error);
} else {
var valid = true
for (var key in data) {
if (data.hasOwnProperty(key)) {
var criterion = document.getElementById(key);
if (criterion) {
if (data[key] == false) {
valid = false;
if (criterion.classList.contains('list-group-item-success')) {
criterion.classList.remove('list-group-item-success');
criterion.classList.add('list-group-item-danger');
};
} else {
if (criterion.classList.contains('list-group-item-danger')) {
criterion.classList.remove('list-group-item-danger');
criterion.classList.add('list-group-item-success');
};
};
};
};
};
if (valid == true) {
$('#successBox').modal('show');
};
}
}
});
return false;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Invalid Code</title>
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ contactMessage }}</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<style>
.card-body {
width: 100%;
height: 100%;
padding: 10%;
}
.slider {
width: 100%;
height: 100%;
display: flex;
overflow-x: hidden;
-webkit-overflow-scrolling: none;
scroll-snap-type: x mandatory;
}
.slider > div {
scroll-snap-align: start;
}
.slide {
width: 100%;
flex-shrink: 0;
height: 100%;
margin: 10%;
}
</style>
<title>Setup - Jellyfin Accounts</title>
</head>
<body>
<div class="pageContainer">
<div class="container">
<div class="row">
<div class="col-sm"></div>
<div id="setupCarousel" class="col-md-auto slider">
<div class="slide card text-center" id="page-1">
<div class="card-body">
<h5 class="card-title">Welcome!</h5>
<p class="card-text">
You'll need to do a few things to start using jellyfin-accounts. Click below to get started, or quit and edit the config file manually.
</p>
<a class="btn btn-primary nextButton" href="#page-2">Get Started</a>
</div>
<div class="card-footer">
<small>Note: Make sure you are accessing this page through HTTPS, or on a private network.</small>
</div>
</div>
<div class="slide card" id="page-2">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p class="card-text">
To access the admin page, you'll need to login. Choose how below.
<ul>
<li><b>Authorize through Jellyfin: </b>Checks credentials with jellyfin, allowing you to share login details and grant multiple users access.</li>
<li><b>Username & Password: </b>Set your own username and password manually.</li>
</ul>
<div class="form-check" id="jfAuthFormGroup">
<input class="form-check-input" type="radio" name="auth" id="jfAuthRadio" value="jfAuth" checked>
<label class="form-check-label" for="jfAuthRadio">
Authorize through Jellyfin
</label>
</div>
<div id="adminOnlyArea">
<div class="form-check" style="margin-left: 1rem;">
<input type="checkbox" class="form-check-input" id="jfAuthAdminOnly" checked>
<label for="jfAuthAdminOnly" class="form-check-label">Allow admin users only</label>
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="auth" id="manualAuthRadio" value="manualAuth">
<label class="form-check-label" for="manualAuthRadio">
Manual username &amp; password
</label>
</div>
<div id="manualAuthArea">
<div class="form-group">
<label for="manualAuthUsername">Username</label>
<input type="text" class="form-control" id="manualAuthUsername" placeholder="Username">
</div>
<div class="form-group">
<label for="manualAuthPassword">Password</label>
<input type="password" class="form-control" id="manualAuthPassword" placeholder="Password">
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-1">Back</a>
<a class="btn btn-primary nextButton" href="#page-3">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-3">
<div class="card-body">
<h5 class="card-title">Jellyfin</h5>
<p class="card-text">
jellyfin-accounts needs admin access so that it can create users.
You should create a separate account for it, checking 'Allow this user to manage the server'. You can disable everything else. Once done, enter the credentials here.
<div class="form-group">
<label for="jfHost">Host</label>
<input type="url" class="form-control" id="jfHost" placeholder="http://jellyf.in:443">
</div>
<div class="form-group">
<label for="jfUser">Username</label>
<input type="text" class="form-control" id="jfUser" placeholder="Username">
</div>
<div class="form-group">
<label for="jfPassword">Password</label>
<input type="password" class="form-control" id="jfPassword" placeholder="Password">
</div>
<button class="btn btn-secondary" id="jfTestButton">Test</button>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-2">Back</a>
<a class="btn btn-primary nextButton disabled" id="jfNextButton" aria-disabled="true" href="#page-4">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-4">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">jellyfin-accounts is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through <a href="https://www.mailgun.com/">Mailgun's</a> API.
<div class="form-group">
<div class="form-check" id="emailDisabled">
<input class="form-check-input" type="radio" name="email" id="emailDisabledRadio" value="emailDisabled">
<label class="form-check-label" for="emailDisabledRadio">
Disabled
</label>
</div>
<div class="form-check" id="emailSMTP">
<input class="form-check-input" type="radio" name="email" id="emailSMTPRadio" value="emailSMTP" checked>
<label class="form-check-label" for="emailSMTPRadio">
SMTP
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="email" id="emailMailgunRadio" value="emailMailgun">
<label class="form-check-label" for="emailMailgunRadio">
Mailgun API
</label>
</div>
</div>
<div id="emailSMTPArea">
<div class="form-group form-check">
<input type="checkbox" class="custom-control-input" id="emailSSL_TLS" checked>
<label for="emailSSL_TLS" class="custom-control-label" id="emailSSL_TLSLabel">Use SSL/TLS</label>
<small class="form-text text-muted">Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587.</small>
</div>
<div class="form-group form-row">
<div class="col">
<input type="text" class="form-control" id="emailSMTPServer" placeholder="SMTP Server Address">
</div>
<div class="col">
<input type="number" class="form-control" id="emailSMTPPort" placeholder="Port">
</div>
</div>
<div class="form-group form-row">
<div class="col">
<input type="email" class="form-control" id="emailSMTPAddress" placeholder="jellyfin@jellyf.in">
</div>
<div class="col">
<input type="password" class="form-control" id="emailSMTPPassword" placeholder="Password">
</div>
</div>
</div>
<div id="emailMailgunArea">
<div class="form-group">
<input type="url" class="form-control" id="emailMailgunURL" placeholder="API URL">
</div>
<div class="form-group">
<input type="password" class="form-control" id="emailMailgunKey" placeholder="API Key">
</div>
<div class="form-group">
<input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in">
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-3">Back</a>
<a class="btn btn-primary nextButton" id="emailNextButton" href="#page-5">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-5">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">Just a few more things to get your emails looking great.
<div class="form-group">
<label for="emailSender">Sender: The name shown when a user receives an email.</label>
<input type="text" class="form-control" id="emailSender" value="Jellyfin">
</div>
<div class="form-group">
<label for="emailDateFormat">Date Format: Follows <a target="_blank" href="https://strftime.org/">strftime</a> format.</label>
<input type="text" class="form-control" id="emailDateFormat" value="%d/%m/%y">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email24hTimeRadio" value="email24hTime" checked>
<label class="form-check-label" for="email24hTimeRadio">24h time</label>
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email12hTimeRadio" value="email12hTime">
<label class="form-check-label" for="email12hTimeRadio">12h time</label>
</div>
<div class="form-group">
<label for="emailMessage">Message: Short message displayed at the bottom of emails.</label>
<input type="text" class="form-control" id="emailMessage" value="Need help? Contact me.">
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-4">Back</a>
<a class="btn btn-primary nextButton" href="#page-6">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-6">
<div class="card-body">
<h5 class="card-title">Password Resets</h5>
<p class="card-text">
When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jellyfin-accounts will then read this file, and send the PIN to the user's email. Try it now, and put the folder that it informs you it put the file in below. Also, if enter a custom email subject if you don't like the default one.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="pwrEnabled" value="enabled">
<label class="form-check-label" for="pwrEnabled">Enabled</label>
</div>
<div id="pwrArea">
<div class="form-group">
<label for="pwrJfPath">Path to Jellyfin</label>
<input type="text" class="form-control" id="pwrJfPath" placeholder="Folder">
</div>
<div class="form-group">
<label for="pwrSubject">Email Subject</label>
<input type="text" class="form-control" id="pwrSubject" value="Password Reset - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-5">Back</a>
<a class="btn btn-primary nextButton" href="#page-7">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-7">
<div class="card-body">
<h5 class="card-title">Invite Emails</h5>
<p class="card-text">
Allows you to send an invite code directly to a specified email address.
Since you'll most likely being running this behind a reverse proxy, the program has no way of knowing the address it will be accessed from. This is needed for sending emails with links. Write your URL Base with the protocol and append '/invite', e.g:
<ul>
<li>On the local network, you might use <a href="#">http://localhost:8056/invite</a></li>
<li>Exposed to the internet, you might use <a href="#">https://accounts.jellyf.in/invite</a></li>
</ul>
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="invEnabled" value="enabled" checked>
<label class="form-check-label" for="invEnabled">Enabled</label>
</div>
<div id="invArea">
<div class="form-group">
<label for="invURLBase">URL Base</label>
<input type="url" class="form-control" id="invURLBase" placeholder="https://accounts.jellyf.in/invite">
</div>
<div class="form-group">
<label for="invSubject">Subject</label>
<input type="text" class="form-control" id="invSubject" value="Invite - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-6">Back</a>
<a class="btn btn-primary nextButton" href="#page-8">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-8">
<div class="card-body">
<h5 class="card-title">Password Validation</h5>
<p class="card-text">
Enabling this will display a set of password requirements on the create account page, such as minimum length, uppercase characters, special characters, etc.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="valEnabled" value="enabled">
<label class="form-check-label" for="valEnabled">Enabled</label>
</div>
<div id="valArea">
<div class="form-group">
<label for="valLength">Minimum Length</label>
<input type="number" class="form-control" id="valLength" value="8">
</div>
<div class="form-group">
<label for="valUpper">Minimum number of uppercase characters</label>
<input type="number" class="form-control" id="valUpper" value="1">
</div>
<div class="form-group">
<label for="valLower">Minimum number of lowercase characters</label>
<input type="number" class="form-control" id="valLower" value="0">
</div>
<div class="form-group">
<label for="valNumber">Minimum number of numbers</label>
<input type="number" class="form-control" id="valNumber" value="0">
</div>
<div class="form-group">
<label for="valSpecial">Minimum number of special characters</label>
<input type="number" class="form-control" id="valSpecial" value="0">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" id="valBackButton" href="#page-7">Back</a>
<a class="btn btn-primary nextButton" href="#page-9">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-9">
<div class="card-body">
<h5 class="card-title">Help Messages</h5>
<p class="card-text">
Just a few little messages that will display in various places. Leave these alone if you want.
</p>
<div class="form-group">
<label for="msgContact">Contact message: Displays at bottom of all pages (except admin).</label>
<input id="msgContact" type="text" class="form-control" value="Need help? Contact me.">
</div>
<div class="form-group">
<label for="msgHelp">Help message: Displays when a user is creating an account.</label>
<input id="msgHelp" type="text" class="form-control" value="Enter your details to create an account.">
</div>
<div class="form-group">
<label for="msgSuccess">Success message: Displays when a user successfully creates an account, just above a button taking the user to Jellyfin.</label>
<input id="msgSuccess" type="text" class="form-control" value="Your account has been created. Click below to continue to Jellyfin.">
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-8">Back</a>
<a class="btn btn-primary nextButton" href="#page-10">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-10">
<div class="card-body text-center">
<h5 class="card-title">Finished!</h5>
<p class="card-text">
Press the button below to submit your settings. The program will quit, so run it again, then refresh this page.
</p>
<button id="submitButton" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
<div class="col-sm"></div>
</div>
</div>
</div>
<script src="setup.js"></script>
</body>
</html>

View File

@@ -8,8 +8,8 @@ from email.mime.multipart import MIMEMultipart
from pathlib import Path
from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader
from __main__ import config
from __main__ import email_log as log
from jellyfin_accounts import config
from jellyfin_accounts import email_log as log
class Email():

View File

@@ -6,8 +6,8 @@ from itsdangerous import (TimedJSONWebSignatureSerializer
as Serializer, BadSignature, SignatureExpired)
from passlib.apps import custom_app_context as pwd_context
import uuid
from __main__ import config, app, g
from __main__ import auth_log as log
from jellyfin_accounts import config, app, g
from jellyfin_accounts import auth_log as log
from jellyfin_accounts.jf_api import Jellyfin
from jellyfin_accounts.web_api import jf

View File

@@ -4,8 +4,8 @@ from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from jellyfin_accounts.email import Mailgun, Smtp
from jellyfin_accounts.web_api import jf
from __main__ import config, data_store
from __main__ import email_log as log
from jellyfin_accounts import config, data_store
from jellyfin_accounts import email_log as log

View File

@@ -1,8 +1,8 @@
from flask import request, jsonify, render_template
from configparser import RawConfigParser
from jellyfin_accounts.jf_api import Jellyfin
from __main__ import config, config_path, app, first_run
from __main__ import web_log as log
from jellyfin_accounts import config, config_path, app, first_run
from jellyfin_accounts import web_log as log
import os
if first_run:

View File

@@ -1,8 +1,8 @@
import json
from pathlib import Path
from flask import Flask, send_from_directory, render_template
from __main__ import config, app, g, css, data_store
from __main__ import web_log as log
from jellyfin_accounts import config, app, g, css, data_store
from jellyfin_accounts import web_log as log
from jellyfin_accounts.web_api import checkInvite, validator

View File

@@ -4,8 +4,8 @@ import json
import datetime
import secrets
import time
from __main__ import config, config_path, app, g, data_store
from __main__ import web_log as log
from jellyfin_accounts import config, config_path, app, g, data_store
from jellyfin_accounts import web_log as log
from jellyfin_accounts.validate_password import PasswordValidator
def resp(success=True, code=500):