...
 
Commits (7)
......@@ -5,7 +5,6 @@ db.sqlite3
venv/
.idea/
node_modules/
bower_components/
.sass-cache/
staticfiles/
mailadmin/static/mailadmin/dist/
# Installation
apt-get install libldap2-dev python-dev libsasl2-dev libssl-dev ldap-utils libffi-dev
virtualenv venv
. venv/bin/activate
pip install -r requirements.txt
# Create or modify local_settings.py
python manage.py migrate
```shell script
apt-get install libldap2-dev python-dev libsasl2-dev libssl-dev ldap-utils libffi-dev
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
# Create or modify local_settings.py
python manage.py migrate
```
## LDAP
sudo docker pull osixia/openldap
sudo docker run -e LDAP_DOMAIN=neuf.no -e LDAP_ORGANISATION="Neuf" -e LDAP_ROOTPASS="toor" -p 389:389 -d osixia/openldap
ldapadd -D "cn=admin,dc=neuf,dc=no" -w "toor" -f testdata.ldif
# Verify import
ldapsearch -x -b dc=neuf,dc=no
```shell script
docker pull osixia/openldap
docker run -e LDAP_DOMAIN=neuf.no -e LDAP_ORGANISATION="Neuf" -e LDAP_ROOTPASS="toor" -p 389:389 -d osixia/openldap
ldapadd -D "cn=admin,dc=neuf,dc=no" -w "toor" -f testdata.ldif
# Verify import
ldapsearch -x -b dc=neuf,dc=no
```
## Django postfix dovecot
* Get the code from https://git.neuf.no/edb/django-postfix-dovecot-api and follow the README
* Setup a user with `python manage.py createsuperuser` in that project
* Update the env variables `DPD_API_USERNAME` and `DPD_API_PASSWORD` in this project
* Start the server on port 8080 `python manage.py runserver 8080`
# Development
python manage.py runserver
# Log in with username 'test@example.com' and password 'test'
# Deploy
sudo apt install fabric # system requirement (not in requirements.txt because of lacking python3 support)
fab deploy
# Development tasks
```shell script
python manage.py runserver
python manage.py createsuperuser
```
# Deployment
```shell script
fab deploy
```
\ No newline at end of file
from contextlib import contextmanager as _contextmanager
from fabric.api import run, sudo, env, cd, prefix, lcd
import os
env.use_ssh_config = True
env.hosts = ['dreamcast.neuf.no']
env.project_path = '/var/www/neuf.no/mailadmin'
env.user = 'gitdeploy'
env.activate = 'source {}/venv/bin/activate'.format(env.project_path)
from fabric import Connection
from invoke import task
@_contextmanager
def virtualenv():
with cd(env.project_path):
with prefix(env.activate):
yield
@task()
def deploy(c):
"""Make sure proxy_user is set to your neuf username."""
project_path = '/var/www/neuf.no/mailadmin'
proxy_user = os.getenv('DEPLOY_USER', os.getenv('USER'))
c = Connection(host='gitdeploy@dreamcast.neuf.no', gateway=Connection('login.neuf.no', user=proxy_user))
def deploy():
with virtualenv():
run('git pull') # Get source
run('pip install -r requirements.txt') # install deps in virtualenv
run('python manage.py collectstatic --noinput -i node_modules -i bower_components') # Collect static
run('python manage.py migrate') # Run DB migrations
with c.cd(project_path), c.prefix('source {}/venv/bin/activate'.format(project_path)):
c.run('git pull') # Get source
c.run('pip install -r requirements.txt') # install deps in virtualenv
with c.cd('mailadmin/static/mailadmin'): # install and compile frontend deps
c.run('npm i')
c.run('npm run build')
c.run('python manage.py collectstatic --noinput -i node_modules') # Collect static
c.run('python manage.py migrate') # Run DB migrations
# Reload gunicorn
sudo('/usr/bin/supervisorctl pid lister.neuf.no | xargs kill -HUP', shell=False)
c.sudo('/usr/bin/supervisorctl pid lister.neuf.no | xargs kill -HUP', shell=False)
@task
def build(c):
with c.cd('mailadmin/static/mailadmin'):
c.run('npm i')
c.run('npm run build')
......@@ -30,7 +30,7 @@ class DjangoPostfixDovecotAPI(object):
self.username = username
self.password = password
def _api(self, method, path, params=None, json=None):
def _request(self, method, path, params=None, json=None):
headers = {'content-type': 'application/json'}
url = "{}{}".format(self.base_url, path)
......@@ -64,10 +64,10 @@ class DjangoPostfixDovecotAPI(object):
return ret
def create_aliases(self, aliases):
return self._api('POST', '/aliases/create_bulk/', json=aliases)
return self._request('POST', '/aliases/create_bulk/', json=aliases)
def delete_aliases(self, aliases):
return self._api('DELETE', '/aliases/delete_bulk/', json=aliases)
return self._request('DELETE', '/aliases/delete_bulk/', json=aliases)
def list_aliases_regex(self, regex):
""" Limit aliases by regular expression and domain name
......@@ -77,7 +77,7 @@ class DjangoPostfixDovecotAPI(object):
'domain__name': settings.NEUF_EMAIL_DOMAIN_NAME,
'source__iregex': regex
}
return self._api(
return self._request(
'GET',
'/aliases/',
params=params
......@@ -89,7 +89,7 @@ class DjangoPostfixDovecotAPI(object):
if name is not None:
params['name'] = name
return self._api(
return self._request(
'GET',
'/domains/',
params=params
......
......@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=500, null=True, blank=True)),
('orgunit', models.ForeignKey(related_name='prefixes', to='mailadmin.OrgUnit')),
('orgunit', models.ForeignKey(related_name='prefixes', to='mailadmin.OrgUnit', on_delete='models.CASCADE')),
],
options={
},
......
from __future__ import unicode_literals
from django.contrib.auth.models import Group
from django.db import models
......@@ -31,7 +30,7 @@ class Prefix(models.Model):
return self.name or ''
name = models.CharField(max_length=500, blank=True, null=True)
orgunit = models.ForeignKey('mailadmin.OrgUnit', related_name='prefixes')
orgunit = models.ForeignKey('mailadmin.OrgUnit', models.CASCADE, related_name='prefixes')
objects = PrefixQueryset.as_manager()
......
......@@ -8,7 +8,7 @@ class SourcePrefixOwner(permissions.BasePermission):
""" Ref: http://www.django-rest-framework.org/api-guide/permissions """
def has_permission(self, request, view):
assert hasattr(view, 'get_user_prefixes')
assert hasattr(view, 'user_prefixes')
# Read permissions (GET, HEAD or OPTIONS) are allowed for all authenticated request
if request.method in permissions.SAFE_METHODS or request.method not in ['DELETE', 'POST']:
......@@ -22,13 +22,13 @@ class SourcePrefixOwner(permissions.BasePermission):
if not serializer.is_valid():
return True # Skip auth if not valid data
my_prefixes = view.get_user_prefixes(request)
if len(my_prefixes) == 0:
my_prefixes = view.user_prefixes(request)
if not my_prefixes:
return False
# Prepare regular expression
regexp = my_prefixes.as_regex()
# All source-adresses should match the prefix regexp
# All source adresses should match the prefix regexp
for alias in serializer.initial_data:
if re.search(regexp, alias.get('source', '')) is None:
return False
......
from __future__ import unicode_literals
from django.conf import settings
from django.contrib.auth.models import User, Group
from rest_framework import serializers
......
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": ["@babel/plugin-transform-runtime"]
}
{
"env": {
"browser": true
},
"extends": [
"airbnb-base",
"plugin:prettier/recommended",
"plugin:react/recommended",
"plugin:import/errors",
"plugin:import/warnings"
],
"rules": {
"no-plusplus": "off",
"no-console": "off",
"no-underscore-dangle": "off",
"no-param-reassign": "off",
"no-restricted-syntax": "off",
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
},
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"node": {}, // https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-509384041
"webpack": {
"config": "frontend/webpack.config.js"
}
}
}
,
"plugins": ["prettier", "react", "react-hooks"],
"parser": "babel-eslint"
}
{
"singleQuote": true,
"printWidth": 120,
"arrowParens": "always"
}
This diff is collapsed.
/*
Author : Hunter Perrin
Version : 3.0.0
Link : http://sciactive.com/pnotify/
*/
/* -- Notice */
.ui-pnotify {
top: 36px;
right: 36px;
position: absolute;
height: auto;
z-index: 2;
}
body > .ui-pnotify {
/* Notices in the body context should be fixed to the viewport. */
position: fixed;
/* Ensures notices are above everything */
z-index: 100040;
}
.ui-pnotify-modal-overlay {
background-color: rgba(0, 0, 0, .4);
top: 0;
left: 0;
position: absolute;
height: 100%;
width: 100%;
z-index: 1;
}
body > .ui-pnotify-modal-overlay {
position: fixed;
z-index: 100039;
}
.ui-pnotify.ui-pnotify-in {
display: block !important;
}
.ui-pnotify.ui-pnotify-move {
transition: left .5s ease, top .5s ease, right .5s ease, bottom .5s ease;
}
.ui-pnotify.ui-pnotify-fade-slow {
transition: opacity .6s linear;
opacity: 0;
}
.ui-pnotify.ui-pnotify-fade-slow.ui-pnotify.ui-pnotify-move {
transition: opacity .6s linear, left .5s ease, top .5s ease, right .5s ease, bottom .5s ease;
}
.ui-pnotify.ui-pnotify-fade-normal {
transition: opacity .4s linear;
opacity: 0;
}
.ui-pnotify.ui-pnotify-fade-normal.ui-pnotify.ui-pnotify-move {
transition: opacity .4s linear, left .5s ease, top .5s ease, right .5s ease, bottom .5s ease;
}
.ui-pnotify.ui-pnotify-fade-fast {
transition: opacity .2s linear;
opacity: 0;
}
.ui-pnotify.ui-pnotify-fade-fast.ui-pnotify.ui-pnotify-move {
transition: opacity .2s linear, left .5s ease, top .5s ease, right .5s ease, bottom .5s ease;
}
.ui-pnotify.ui-pnotify-fade-in {
opacity: 1;
}
.ui-pnotify .ui-pnotify-shadow {
-webkit-box-shadow: 0px 6px 28px 0px rgba(0,0,0,0.1);
-moz-box-shadow: 0px 6px 28px 0px rgba(0,0,0,0.1);
box-shadow: 0px 6px 28px 0px rgba(0,0,0,0.1);
}
.ui-pnotify-container {
background-position: 0 0;
padding: .8em;
height: 100%;
margin: 0;
}
.ui-pnotify-container:after {
content: " "; /* Older browser do not support empty content */
visibility: hidden;
display: block;
height: 0;
clear: both;
}
.ui-pnotify-container.ui-pnotify-sharp {
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
}
.ui-pnotify-title {
display: block;
margin-bottom: .4em;
margin-top: 0;
}
.ui-pnotify-text {
display: block;
}
.ui-pnotify-icon, .ui-pnotify-icon span {
display: block;
float: left;
margin-right: .2em;
}
/* Alternate stack initial positioning. */
.ui-pnotify.stack-topleft, .ui-pnotify.stack-bottomleft {
left: 25px;
right: auto;
}
.ui-pnotify.stack-bottomright, .ui-pnotify.stack-bottomleft {
bottom: 25px;
top: auto;
}
.ui-pnotify.stack-modal {
left: 50%;
right: auto;
margin-left: -150px;
}
<div class="alert alert-{{ alertClass }}">
<a class="close" data-dismiss="alert">×</a>
{% if iconClass %}<span class="glyphicon glyphicon-{{ iconClass }}"></span> {% endif %}{{ msg }}
</div>
\ No newline at end of file
{% for alias in aliases %}
<tr class="alias-row">
<td class="alias"><span class="js-destination" title="Lagt til {{ alias.created|datefromnow }} ({{ alias.created|date('LLLL') }})">{{ alias.destination }}</span></td>
<td class="del-cell">
<label class="del-label" for="{{alias.id}}"><input id="{{alias.id}}" type="checkbox" name='fwd-delete' value="{{alias.id}}" data-source="{{alias.source}}" data-destination="{{alias.destination}}" data-domain="{{alias.domain}}"/></label>
</td>
</tr>
{% endfor %}
\ No newline at end of file
{% for list,aliases in lists %}
<div class="fwdlist" data-list-name="{{ list }}" data-domain="{{aliases[0].domain}}">
<div class="panel panel-default">
<div class="panel-heading"><span class="glyphicon glyphicon-list-alt icon-faded" aria-hidden="true"></span> {{ list }} <span class="badge badge-list-total">{{aliases|length}}</span></div>
<table class="table table-condensed">
<tbody>
<!-- Aliases -->
{% include "aliases.html" %}
<!-- Actions: Add/Remove -->
<tr class="action-row">
<td>
<a href="#" class="link-add js-toggle-email-textarea"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Legg til</a>
</td>
<td>
<a href="#" class="link-del js-del-selected" type="button" data-delete-list-name="{{ list }}"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Slett</a>
</td>
</tr>
<tr class="textarea-row">
<td colspan="2">
<h4>Eposter <span class="badge email-counter"></span></h4>
<textarea class="form-control js-add-list-textarea" data-list-name="{{ list }}" placeholder="asdf@studentersamfundet.no, qwerty@studentersamfundet.no f.eks. Klipp og lim så mye du orker" rows="4"></textarea>
<button type="button" class="btn btn-primary btn-add-list js-new-email" data-list-name="{{list}}"><span class="glyphicon glyphicon-plus js-new-email" aria-hidden="true"></span> Legg til</button>
</td>
</tr>
</tbody>
</table>
<div class="result-alert"></div>
</div>
</div>
{% endfor %}
\ No newline at end of file
<div class="panel panel-default">
<div class="panel-heading"><span class="glyphicon glyphicon-list-alt icon-faded" aria-hidden="true"></span> <span class="js-new-list-preview">Ny liste</span></div>
<div class="panel-body">
<div class="input-group">
<span class="input-group-btn">
<button type="button" class="btn btn-default prefix-btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">{{ orgunits[0].prefixes[0] }}- <span class="caret"></span></button>
<ul class="dropdown-menu prefix-select">
{% set first = true %}
{% for ou in orgunits %}
{% for prefix in ou.prefixes %}
<li{% if first %} class="active"{% set first = false %}{% endif %} data-value="{{ prefix }}-"><a href="#">{{prefix}}-</a></li>
{% endfor %}
{% endfor %}
</ul>
</span>
<input type="text" class="form-control js-new-list-name">
<span class="input-group-addon">@{{email_domain.name}}</span>
</div>
<h4>Eposter <span class="badge email-counter"></span></h4>
<textarea class="form-control" data-list-name='newlist@example.com' placeholder="asdf@studentersamfundet.no, qwerty@studentersamfundet.no f.eks. Klipp og lim så mye du orker" rows="4"></textarea>
<button type="button" class="btn btn-primary btn-add-list js-add-list"><span class="glyphicon glyphicon-plus js-add-list" aria-hidden="true"></span> Opprett liste</button>
</div>
</div>
\ No newline at end of file
<li class="active"><a href="/lists/" data-id="" data-prefix=".+">Alle</a></li>
{% for ou in orgunits %}
<li><a href="/lists/?orgunit={{ ou.id }}" data-prefix="{{ ou.prefixes_regex }}" data-id="{{ ou.id }}">{{ ou.name }}</a></li>
{% endfor %}
\ No newline at end of file
<select class="orgunits-select form-control">
<option value="" data-prefix=".+">Alle</option>
{% for ou in orgunits %}
<option value="{{ ou.id }}" data-prefix="{{ou.prefixes_regex }}">{{ ou.name }}</option>
{% endfor %}
</select>
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
'use strict';
var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var del = require('del');
var browserSync = require('browser-sync').create();
var reload = browserSync.reload;
gulp.task('styles', function () {
return gulp.src('app/styles/main.scss')
.pipe($.sass({
includePaths: ['.'],
sourceComments: 'map'
}).on('error', $.sass.logError))
.pipe($.autoprefixer('last 1 version'))
.pipe(gulp.dest('dist/styles'))
.pipe(browserSync.stream());
});
gulp.task('scripts', function () {
return gulp.src('app/scripts/**/*.js')
.pipe($.jshint())
.pipe($.jshint.reporter(require('jshint-stylish')))
.pipe(gulp.dest('dist/scripts'))
});
gulp.task('vendorscripts', function() {
var vendorScripts = [
'node_modules/jquery/dist/jquery.min.js',
'node_modules/underscore/underscore-min.js',
'node_modules/nunjucks/browser/nunjucks-slim.min.js',
'node_modules/moment/min/moment.min.js',
'node_modules/moment/locale/nb.js',
'node_modules/bootstrap-sass/assets/javascripts/bootstrap/alert.js',
'node_modules/bootstrap-sass/assets/javascripts/bootstrap/dropdown.js',
'node_modules/bootstrap-sass/assets/javascripts/bootstrap/collapse.js',
'node_modules/pnotify/dist/pnotify.js',
'node_modules/query-string/query-string.js'
];
return gulp.src(vendorScripts)
.pipe($.concat('vendor.js'))
.pipe(gulp.dest('dist/scripts'));
});
gulp.task('images', function () {
return gulp.src('app/images/**/*')
.pipe($.imagemin({
optimizationLevel: 3,
progressive: true,
interlaced: true
}))
.pipe(gulp.dest('dist/images'))
});
gulp.task('fonts', function () {
return gulp.src(['node_modules/bootstrap-sass/assets/fonts/**/*'])
.pipe($.filter('**/*.{eot,svg,ttf,woff,woff2}'))
.pipe($.flatten())
.pipe(gulp.dest('dist/fonts'))
});
gulp.task('templates', function () {
return gulp.src('app/templates/*.html')
.pipe($.nunjucks())
.pipe($.concat('templates.js'))
.pipe(gulp.dest('dist/scripts'))
});
gulp.task('extras', function () {
return gulp.src(['app/*.*', '!app/*.html', '!app/templates/*.html', '!app/styles/*.scss'], { dot: true })
.pipe(gulp.dest('dist'));
});
gulp.task('clean', del.bind(null, ['dist']));
gulp.task('build', ['styles', 'scripts', 'vendorscripts', 'templates', 'images', 'fonts', 'extras'],
function() {
return gulp.src('dist/**/*').pipe($.size({title: 'build', gzip: true}));
}
);
gulp.task('default', ['clean'], function () {
gulp.start('build');
});
gulp.task('serve', ['styles', 'scripts'], function () {
require('opn')('http://localhost:3000');
});
gulp.task('watch', ['serve'], function () {
browserSync.init({
proxy: 'localhost:8000'
});
// watch for changes
gulp.watch([
'../../templates/**/*.html',
'app/templates/*.html',
'dist/scripts/**/*.js',
'dist/images/**/*'
]).on('change', reload);
gulp.watch('app/styles/**/*.scss', ['styles']);
gulp.watch('app/scripts/**/*.js', ['scripts']);
gulp.watch('app/images/**/*', ['images']);
gulp.watch('app/templates/**/*.html', ['templates']);
});
This diff is collapsed.
{
"name": "mailadmin-www",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "webpack -p --env production --display-error-details --mode production",
"start": "webpack-dev-server --hot --env development --mode development",
"format": "eslint --fix \"src/**/*.js\"",
"lint": "eslint --quiet \"src/**/*.js\""
},
"browserslist": [
"defaults"
],
"dependencies": {
"bootstrap-sass": "^3.3.7",
"jquery": "^3.2.1",
"moment": "^2.18.1",
"nunjucks": "^3.0.1",
"pnotify": "^3.2.1",
"query-string": "^1.0.1",
"underscore": "^1.8.3"
"@apollo/react-hooks": "^3.1.3",
"@babel/runtime": "^7.7.7",
"@fortawesome/fontawesome-free": "^5.12.0",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link-rest": "^0.7.3",
"bootstrap": "^4.4.1",
"classnames": "^2.2.6",
"graphql": "^14.5.8",
"graphql-anywhere": "^4.2.6",
"graphql-tag": "^2.10.1",
"jquery": "^3.4.1",
"pnotify": "^4.0.0",
"popper.js": "^1.16.0",
"qs": "^6.9.1",
"react": "^16.12.0",
"react-dom": "^16.12.0"
},
"devDependencies": {
"browser-sync": "^2.10.0",
"del": "^2.2.0",
"gulp": "^3.9.0",
"gulp-autoprefixer": "^3.0.1",
"gulp-concat": "~2.6.0",
"gulp-csso": "^1.0.1",
"gulp-filter": "^3.0.1",
"gulp-flatten": "^0.2.0",
"gulp-imagemin": "^2.4.0",
"gulp-jshint": "^1.11.2",
"gulp-load-plugins": "^1.0.0-rc",
"gulp-nunjucks": "^2.1.0",
"gulp-rename": "~1.2.2",
"gulp-sass": "~2.0.4",
"gulp-size": "^2.0.0",
"gulp-uglify": "^1.4.1",
"gulp-util": "~3.0.6",
"jshint-stylish": "^2.0.1",
"opn": "^3.0.3"
"@babel/core": "^7.7.7",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/preset-react": "^7.7.4",
"autoprefixer": "^9.7.3",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"browser-sync": "^2.26.7",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.4.0",
"cssnano": "^4.1.10",
"del": "^5.1.0",
"eslint": "^6.8.0",
"eslint-config-airbnb-base": "^14.0.0",
"eslint-config-prettier": "^6.7.0",
"eslint-import-resolver-babel-module": "^5.1.0",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.16.0",
"eslint-plugin-react-hooks": "^2.1.2",
"file-loader": "^5.0.2",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.0",
"postcss-loader": "^3.0.0",
"prettier": "^1.19.1",
"sass-loader": "^8.0.0",
"style-loader": "^1.1.2",
"url-loader": "^3.0.0",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1"
},
"engines": {
"node": ">=0.10.0"
"node": "12"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.js": [
"eslint --quiet"
]
}
}
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');
module.exports = {
plugins: {
'autoprefixer': {},
'cssnano': {}
},
};
import React from 'react';
import { ApolloProvider } from '@apollo/react-hooks';
import { RestLink } from 'apollo-link-rest';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { getCookie } from './utils';
import MailAdmin from './components/MailAdmin';
const restLink = new RestLink({
uri: '/api/',
credentials: 'include',
headers: {
'x-csrftoken': getCookie('csrftoken')
}
});
const client = new ApolloClient({
link: restLink,
cache: new InMemoryCache(),
typeDefs: {}
});
const App = () => (
<ApolloProvider client={client}>
<div className="container" style={{ marginTop: '2rem' }}>
<div className="row">
<MailAdmin />
</div>
</div>
</ApolloProvider>
);
export default App;
import React, { useState, useCallback } from 'react';
import ctx from 'classnames';
import { useMutation } from '@apollo/react-hooks';
import EmailsTextarea from './EmailsTextarea';
import { DELETE_ALIASES_MUTATION } from '../mutations';
import { notify } from '../utils';
import { GET_ALIASES } from '../queries';
const AliasRow = ({ id, destination, query, updateDeletionList }) => {
const [checked, setChecked] = useState(false);
const queryMatch = query && destination.includes(query);
return (
<tr className={ctx({ 'table-danger': checked, 'table-success': queryMatch })}>
<td>{destination}</td>
<td className="del-cell">
<div className="custom-control custom-checkbox custom-control-inline">
<input
id={`alias-delete-${id}`}
type="checkbox"
className="custom-control-input"
value={checked}
onChange={(e) => {
setChecked(e.target.checked);
updateDeletionList(id, e.target.checked);
}}
/>
<label className="custom-control-label" htmlFor={`alias-delete-${id}`} />
</div>
</td>
</tr>
);
};
const ActionRow = ({ onAddEmailClick, deleteVisible, onDelete }) => {
return (
<tr className="action-row">
<td>
<a href="#" className="link-add" onClick={onAddEmailClick}>
<span className="fas fa-plus" aria-hidden="true" /> Legg til
</a>
</td>
<td>
<a href="#" className={ctx('link-del', { visible: deleteVisible })} type="button" onClick={onDelete}>
<span className="fas fa-trash" aria-hidden="true" />
&nbsp;Slett
</a>
</td>
</tr>
);
};
const AliasList = ({ list, aliases, domain, query }) => {
const [textareaVisible, setTextareaVisible] = useState(false);
const [deletionList, setDeletionList] = useState([]);
const [deleteAliases] = useMutation(DELETE_ALIASES_MUTATION);
const onDelete = useCallback(
async (e) => {
e.preventDefault();
const input = deletionList.map((alias) => ({ ...alias, domain: domain.id }));
const errorMessage = `Kunne ikke slette e-poster fra ${list}`;
try {
const { errors } = await deleteAliases({
variables: { input },
update: (cache) => {
setDeletionList([]);
const deletedIds = deletionList.map(({ id }) => id);
const { aliases: aliasesFromCache } = cache.readQuery({ query: GET_ALIASES });
cache.writeQuery({
query: GET_ALIASES,
data: { aliases: aliasesFromCache.filter(({ id }) => !deletedIds.includes(id)) }
});
notify('Slettet e-poster', `${deletionList.map((alias) => alias.destination).join('\n')} slettet.`);
}
});
if (errors) {
notify('Feil', errorMessage, 'error');
console.error(errors);
}
} catch (err) {
notify('Feil', errorMessage, 'error');
console.error(err);
}
},
[deleteAliases, deletionList, domain, list]
);
return (
<div className="card mb-3">
<div className="card-header">
<span className="fas fa-list-alt icon-faded" aria-hidden="true" />
{list}
<span className="badge badge-secondary badge-list-total">{aliases.length}</span>
</div>
<table className="table table-striped table-hover table-condensed">
<tbody>
{aliases.map((alias) => (
<AliasRow
key={alias.id}
{...alias}
updateDeletionList={(aliasId, checked) => {
if (checked) {
const { __typename, ...aliasForDeletion } = alias;
setDeletionList([...deletionList, aliasForDeletion]); // add
} else {
setDeletionList(deletionList.filter(({ id }) => id !== aliasId)); // remove
}
}}
query={query}
/>
))}
<ActionRow
onAddEmailClick={(e) => {
e.preventDefault();
setTextareaVisible(!textareaVisible);
}}
deleteVisible={Boolean(deletionList.length)}
onDelete={onDelete}
/>
<tr className={ctx('textarea-row', { visible: textareaVisible })}>
<td colSpan="2">
<EmailsTextarea source={list} domainId={domain.id} />
</td>
</tr>
</tbody>
</table>
</div>
);
};
export default AliasList;
import React, { useState } from 'react';
import ctx from 'classnames';
import EmailsTextarea from './EmailsTextarea';
const AliasListCreate = ({ domain, lists, orgUnits }) => {
const firstOrgUnit = orgUnits[0].prefixes[0];
const [textareaVisible, setTextareaVisible] = useState(false);
const [name, setName] = useState('');
const [selectedPrefix, setSelectedPrefix] = useState(firstOrgUnit);
const listName = `${selectedPrefix}-${name}@${domain.name}`;
const shouldCreate = Boolean(Object.entries(lists).filter(([list]) => list === listName).length);
const flattenedPrefixes = [].concat(...orgUnits.map((ou) => ou.prefixes.map((prefix) => prefix))).sort();
return (
<div className="new-list-container">
<button
type="button"
className="btn btn-outline-primary new-list-btn"
onClick={() => {
setTextareaVisible(!textareaVisible);
}}
>
<span className="fas fa-plus" aria-hidden="true" /> Opprett ny liste
</button>
<div className={ctx('new-list fwdlist card', { visible: textareaVisible })}>
<div className="card-header">
<span className="fas fa-list-alt icon-faded" aria-hidden="true" />
{name ? listName : `Ny liste`}
{name && !shouldCreate && <span className="badge badge-success">Ny</span>}
{name && shouldCreate && <span className="badge badge-info">Eksisterende</span>}
</div>
<div className="card-body">
<div className="new-list-name input-group">
<div className="input-group-prepend">
<button
type="button"
className="btn btn btn-outline-secondary dropdown-toggle"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
{selectedPrefix}- <span className="caret" />
</button>
<div className="dropdown-menu prefix-select">
{flattenedPrefixes.map((prefix) => (
<a
key={prefix}
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
setSelectedPrefix(prefix);
}}
>
{prefix}-
</a>
))}
</div>
</div>
<input
type="text"
className="form-control js-new-list-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="input-group-append">
<span className="input-group-text">{`@${domain.name}`}</span>
</div>
</div>
<EmailsTextarea source={listName} domainId={domain.id} create={shouldCreate} />
</div>
</div>
{/* <div className="new-list-result"></div> */}
</div>
);
};
export default AliasListCreate;
import React, { useState, useCallback } from 'react';
import { useMutation } from '@apollo/react-hooks';
import { parseEmails, notify } from '../utils';
import { CREATE_ALIASES_MUTATION } from '../mutations';
import { GET_ALIASES } from '../queries';
const EmailsTextarea = ({ source, domainId, create = false }) => {
const [rawAliases, setRawAliases] = useState('');
const [createAliases] = useMutation(CREATE_ALIASES_MUTATION);
const aliases = parseEmails(rawAliases);
const onAddClick = useCallback(
async (e) => {
e.preventDefault();
const input = aliases.map((alias) => ({ source, destination: alias, domain: domainId }));
const { errors } = await createAliases({
variables: { input },
update: (cache, { data }) => {
setRawAliases('');
const { createAliasesResponse: newAliases } = data;
const { aliases: aliasesFromCache } = cache.readQuery({ query: GET_ALIASES });
cache.writeQuery({
query: GET_ALIASES,
data: { aliases: aliasesFromCache.concat(newAliases) }
});
const title = `${newAliases[0].source} ${create ? 'opprettet' : 'oppdatert'}`;
notify(title, `${newAliases.map((alias) => alias.destination).join('\n')} lagt til.`);
}
});
if (errors) {
notify('Feil', `Kunne ikke legge til e-poster til liste ${source}`, 'error');
console.error(errors);
}
},
[aliases, create, createAliases, domainId, source]
);
return (
<>
<h5>
Eposter <span className="badge badge-secondary email-counter">{!aliases.length ? '' : aliases.length}</span>
</h5>
<textarea
className="form-control"
placeholder="asdf@studentersamfundet.no, qwerty@studentersamfundet.no f.eks. Klipp og lim så mye du orker"
rows="4"
value={rawAliases}
onChange={(e) => {
setRawAliases(e.target.value);
}}
/>
<div className="text-right">
<button type="button" className="btn btn-primary btn-add-list" onClick={onAddClick}>
<span className="fas fa-plus" /> {create ? 'Opprett liste' : 'Legg til'}
</button>
</div>
</>
);
};
export default EmailsTextarea;
import React, { useState } from 'react';
import { useQuery } from '@apollo/react-hooks';
import { MainArea, OrgUnitList } from './index';
import { GET_ORG_UNITS } from '../queries';
const MailAdmin = () => {
const [query, setQuery] = useState(new URLSearchParams(window.location.search).get('q') || '');
const [selectedOrgUnit, setOrgUnit] = useState(
parseInt(new URLSearchParams(window.location.search).get('orgunit'), 10) || null
);
const { data, loading, error } = useQuery(GET_ORG_UNITS);
if (loading || error) {
return null;
}
// Format a prefix regex for each org unit
const orgUnits = data.orgUnits.map((ou) => {
const prefixes = ou.prefixes.map((prefix) => {
return `^${prefix}-|^${prefix}@`;
});
return { prefixesRegex: prefixes.join('|'), ...ou };
});
return (
<>
<OrgUnitList
query={query}
setQuery={setQuery}
selectedOrgUnit={selectedOrgUnit}
setOrgUnit={setOrgUnit}
orgUnits={orgUnits}
/>
<MainArea query={query} selectedOrgUnit={selectedOrgUnit} orgUnits={orgUnits} />
</>
);
};
export default MailAdmin;
import React from 'react';
import { useQuery } from '@apollo/react-hooks';
import { GET_ALIASES, GET_DOMAINS } from '../queries';
import AliasList from './AliasList';
import AliasListCreate from './AliasListCreate';
import { groupBy } from '../utils';
const MainArea = ({ query, selectedOrgUnit, orgUnits }) => {
const { data, loading, error } = useQuery(GET_ALIASES);
const { data: domainData, loading: domainLoading, error: domainError } = useQuery(GET_DOMAINS);
if (loading || domainLoading) {
return (
<div className="forwards-container">
<span className="fas fa-sync spin" aria-hidden="true" /> Laster...
</div>
);
}
if (error || domainError) {
return null;
}
const domain = domainData.domains[0];
const lists = groupBy(
data.aliases.sort((a, b) => a.source.localeCompare(b.source)),
'source'
);
const listPrefixes = selectedOrgUnit && orgUnits.find(({ id }) => id === selectedOrgUnit).prefixesRegex;
const listRegex = listPrefixes && new RegExp(listPrefixes);
return (
<div className="col-sm-8">
<AliasListCreate lists={lists} orgUnits={orgUnits} domain={domain} />
<div className="forwards-container">
{Object.entries(lists).map(([list, aliases]) => {
// When searching only render lists with query matches
const queryMatchInList = aliases.some((alias) => alias.destination.includes(query));
if (query && !queryMatchInList) {
return null;
}
// Only render lists which matches the org units prefixes
if (listRegex && !listRegex.test(list)) {
return null; // FIXME: use styles to show/hide ?
}
return <AliasList key={list} list={list} aliases={aliases} domain={domain} query={query} />;
})}
</div>
</div>
);
};
export default MainArea;
import React, { useCallback } from 'react';
import ctx from 'classnames';
import { setQueryString } from '../utils';
const Search = ({ query, setQuery }) => {
return (
<input
type="search"
className="form-control search-field js-lists-filter-field"
placeholder="Søk i listene"
onChange={setQuery}
value={query}
/>
);
};
const OrgUnit = ({ id, name, selected, onClick }) => (
<li className={ctx({ active: selected })}>
<a href={`/lists/?orgunit=${id}`} onClick={onClick}>
{name}
</a>
</li>
);
const OrgUnitList = ({ query, setQuery, orgUnits, selectedOrgUnit, setOrgUnit }) => {
const setSearchQuery = useCallback(
(e) => {
e.preventDefault();
const q = e.target.value;
setQuery(q);
setQueryString(q ? { q } : null);
},
[setQuery]
);
const onClearOrgUnit = useCallback(
(e) => {
e.preventDefault();
setOrgUnit(null);
setQueryString();
setQuery('');
},
[setOrgUnit, setQuery]
);
return (
<div className="col-sm-4 sidebar">
<Search query={query} setQuery={setSearchQuery} />
<h5>Foreninger/Utvalg</h5>
<ul className="nav nav-sidebar orgunit-list">
<li className={ctx({ active: selectedOrgUnit === null })}>
<a href="#" onClick={onClearOrgUnit}>
Alle
</a>
</li>
{orgUnits.map((ou) => (
<OrgUnit
key={ou.id}
{...ou}
selected={selectedOrgUnit === ou.id}
onClick={(e) => {
e.preventDefault();
setOrgUnit(ou.id);
setQueryString({ orgunit: ou.id });
setQuery('');
}}
/>
))}
</ul>
</div>
);
};
export default OrgUnitList;
export { default as OrgUnitList } from './OrgUnitList';
export { default as MainArea } from './MainArea';
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Development</title>
</head>
<body>
<section id="app"></section>
</body>
</html>
\ No newline at end of file
import 'bootstrap/js/dist/util';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/dropdown';
import React from 'react';
import { render } from 'react-dom';
import './index.scss';
import App from './App';
const el = document.getElementById('app');
render(<App />, el);
@import "~bootstrap/scss/bootstrap";
@import "./styles/animations";
@import "./styles/mainarea";
@import "./styles/sidebar";
@import "./styles/fonts";
@import "./styles/login";
@import "./styles/footer";
import gql from 'graphql-tag';
export const CREATE_ALIASES_MUTATION = gql`
mutation createAliases($input: [Alias]!) {
createAliasesResponse(input: $input) @rest(type: "Alias", path: "aliases/", method: "POST") {
id
source
destination
}
}
`;
export const DELETE_ALIASES_MUTATION = gql`
mutation deleteAliases($input: [Alias]!) {
deleteAliasesResponse(input: $input) @rest(type: "Alias", path: "aliases/delete/", method: "POST") {
id
}
}
`;
import gql from 'graphql-tag';
export const GET_ORG_UNITS = gql`
query OrgUnits {
orgUnits @rest(type: "OrgUnit", path: "orgunits/") {
id
name
prefixes
}
}
`;
export const GET_DOMAINS = gql`
query Domains {
domains @rest(type: "Domain", path: "domains/") {
id
name
}
}
`;
export const GET_ALIASES = gql`
query Aliases {
aliases @rest(type: "Alias", path: "aliases/") {
id
source
destination
}
}
`;
$fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
// Skip EOT and SVG fonts, ref:
// @import "~@fortawesome/fontawesome-free/scss/solid";
@font-face {
font-family: 'Font Awesome 5 Free';
font-style: normal;
font-weight: 900;
font-display: $fa-font-display;
src: url('#{$fa-font-path}/fa-solid-900.woff2') format('woff2'),
url('#{$fa-font-path}/fa-solid-900.woff') format('woff'),
url('#{$fa-font-path}/fa-solid-900.ttf') format('truetype'),
}
.fa,
.fas {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
}
.login-form {
margin: 0 auto;
max-width: 300px;
float: none;
input[type=submit] {
float: right;
}
}
$icon-font-path: "../fonts/";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap";
@import "pnotify.core";
@import "animations";
.login-form {
margin: 0 auto;
max-width: 300px;
float: none;
input[type=submit] {
float: right;
}
}
/* Hide for mobile, show later */
.sidebar {
.orgunit-list {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
.search-field {
margin-bottom: 20px;
}
.orgunit-list {
margin-bottom: 40px;
}
.orgunit-select-container {
display: block;
margin-bottom: 20px;
@media (min-width: 768px) {
display: none;
}
}
/* Sidebar navigation */
.nav-sidebar > .active > a,
.nav-sidebar > .active > a:hover,
.nav-sidebar > .active > a:focus {
color: #fff;
background-color: #428bca;
}
/* Sticky footer styles */
.footer {
margin-top: 40px;
}
/* Lists */
.del-cell {
text-align: center;
width: 82px;
padding: 0 !important;
text-align: right;
width: 54px;
padding: 2px 0 0 !important;
vertical-align: middle;
}
.del-label {
display: block;
height: 30px;
margin: 0;
text-align: center;
input {
margin: 0;
position: relative;
top: 6px;
}
}
.forwards-container table > tbody > tr > td:first-child {
padding-left: 16px;
padding-right: 16px;
}
.forwards-container .badge-list-total {
.badge-list-total {
float: right;
margin-right: 14px;
position: relative;
top: 3px;
}