浏览代码

feat(webapp): adds front and sign up pages, reworks sign-up flow

Nils Wisiol 5 年之前
父节点
当前提交
f94e0fdd52

+ 1 - 9
api/desecapi/signals.py

@@ -1,17 +1,9 @@
 from django.db.models.signals import post_save
 from django.dispatch import receiver
 
-from api import settings
 from desecapi import models
 
 
 @receiver(post_save, sender=models.Domain, dispatch_uid=__name__)
 def domain_handler(sender, instance: models.Domain, created, raw, using, update_fields, **kwargs):
-    if instance.is_locally_registrable:
-        instance.owner.send_email('domain-dyndns', context={
-            'domain': instance.name,
-            'url': f'https://update.{instance.parent_domain_name}/',
-            'username': instance.name,
-            'password': models.Token.objects.create(user=instance.owner, name='dyndns').plain,
-            'desecstack_domain': settings.DESECSTACK_DOMAIN,
-        })
+    pass

+ 2 - 3
api/desecapi/templates/emails/activate-with-domain/content.txt

@@ -2,9 +2,8 @@ Hi there,
 
 Thank you for registering with deSEC!
 
-As we may need to contact you in the future, you need to verify your
-email address before we can register your domain. To do so, please use
-the following link:
+To create your account and register {{ domain }}, please confirm you
+received this email by clicking on the following link:
 
 {{ confirmation_link }}
 

+ 0 - 34
api/desecapi/templates/emails/domain-dyndns/content.txt

@@ -1,34 +0,0 @@
-Hi there,
-
-And welcome to the deSEC dynDNS service! I'm Nils, CTO of deSEC.
-If you have any questions or concerns, please do not hestitate
-to contact me.
-
-To get started using your new dynDNS domain {{ domain }},
-please configure your device (or any other dynDNS client) to use
-the following credentials:
-
-  url:      {{ url }}
-  username: {{ username }}
-  password: {{ password }}
-
-Alternatively, you can update your dynDNS IP record by visiting
-this page:
-
-  https://update.dedyn.{{ desecstack_domain }}/update?username={{ username }}&password={{ password }}
-
-If your router does not support dynDNS, you might want to
-bookmark this URL.
-
-You can use our website to see if everything works as expected:
-
-  https://dedyn.io/check?{{ domain }}#{{ password }}
-
-We know there is always room for improvement, so please shoot me
-an email if we can do anything better.
-
-Thanks for using deSEC, we hope you do enjoy your DNSSEC-enabled
-dynDNS.
-
-Stay secure,
-Nils

+ 0 - 1
api/desecapi/templates/emails/domain-dyndns/subject.txt

@@ -1 +0,0 @@
-Welcome to deSEC, {{ domain }}!

+ 2 - 8
api/desecapi/tests/test_domains.py

@@ -397,12 +397,7 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
             with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name=name)):
                 response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
                 self.assertStatus(response, status.HTTP_201_CREATED)
-                self.assertEqual(len(mail.outbox), i + 1)
-                email = str(mail.outbox[0].message())
-                self.assertTrue(name in email)
-                password_plain = re.search('password: (.*)', email).group(1)
-                self.assertToken(password_plain)
-                self.assertFalse(self.user.plain_password in email)
+                self.assertFalse(mail.outbox)  # do not send email
 
     def test_domain_limit(self):
         url = self.reverse('v1:domain-list')
@@ -413,11 +408,10 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
             with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name)):
                 response = self.client.post(url, {'name': name})
                 self.assertStatus(response, status.HTTP_201_CREATED)
-                self.assertEqual(len(mail.outbox), i + 1)
 
         response = self.client.post(url, {'name': self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)})
         self.assertContains(response, 'Domain limit', status_code=status.HTTP_403_FORBIDDEN)
-        self.assertEqual(len(mail.outbox), user_quota)
+        self.assertFalse(mail.outbox)  # do not send email
 
     def test_domain_minimum_ttl(self):
         url = self.reverse('v1:domain-list')

+ 2 - 5
api/desecapi/tests/test_user_management.py

@@ -265,13 +265,10 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
         )
 
     def assertRegistrationWithDomainVerificationSuccessResponse(self, response, domain=None, email=None):
+        self.assertNoEmailSent()  # do not send email in any case
         if domain and self.has_local_suffix(domain):
-            body = self.assertEmailSent('', body_contains=domain, recipient=email)
-            password_plain = re.search('password: (.*)', body).group(1)
-            self.assertToken(password_plain, user=User.objects.get(email=email))
-            text = 'Success! Here is the secret token'
+            text = 'Success! Here is the password'
         else:
-            self.assertNoEmailSent()
             text = 'Success! Please check the docs for the next steps'
         self.assertContains(response=response, text=text, status_code=status.HTTP_200_OK)
 

+ 13 - 6
api/desecapi/views.py

@@ -6,6 +6,7 @@ from django.conf import settings
 from django.contrib.auth import user_logged_in
 from django.core.mail import EmailMessage
 from django.http import Http404
+from django.shortcuts import redirect
 from django.template.loader import get_template
 from rest_framework import generics
 from rest_framework import mixins
@@ -13,6 +14,7 @@ from rest_framework import status
 from rest_framework.authentication import get_authorization_header, BaseAuthentication
 from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
 from rest_framework.permissions import IsAuthenticated
+from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
@@ -397,7 +399,8 @@ class AccountCreateView(generics.CreateAPIView):
                 action = models.AuthenticatedActivateUserAction(user=user, domain=domain)
                 verification_code = serializers.AuthenticatedActivateUserActionSerializer(action).data['code']
                 user.send_email('activate-with-domain' if domain else 'activate', context={
-                    'confirmation_link': reverse('confirm-activate-account', request=request, args=[verification_code])
+                    'confirmation_link': reverse('confirm-activate-account', request=request, args=[verification_code]),
+                    'domain': domain,
                 })
 
         # This request is unauthenticated, so don't expose whether we did anything.
@@ -568,6 +571,7 @@ class AuthenticatedActionView(generics.GenericAPIView):
 
 class AuthenticatedActivateUserActionView(AuthenticatedActionView):
     http_method_names = ['get']
+    renderer_classes = [JSONRenderer, StaticHTMLRenderer]
     serializer_class = serializers.AuthenticatedActivateUserActionSerializer
 
     def finalize(self):
@@ -608,11 +612,14 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
         # TODO the following line raises Domain.DoesNotExist under unknown conditions
         PDNSChangeTracker.track(lambda: DomainList.auto_delegate(domain))
         token = models.Token.objects.create(user=domain.owner, name='dyndns')
-        return Response({
-            'detail': 'Success! Here is the password ("auth_token") to configure your router (or any other dynDNS '
-                      'client). This password is different from your account password for security reasons.',
-            **serializers.TokenSerializer(token).data,
-        })
+        if self.request.accepted_renderer.format == 'html':
+            return redirect(f'/app/dynsetup/{domain.name}/#{token.plain}')
+        else:
+            return Response({
+                'detail': 'Success! Here is the password ("auth_token") to configure your router (or any other dynDNS '
+                          'client). This password is different from your account password for security reasons.',
+                **serializers.TokenSerializer(token).data,
+            })
 
     def _finalize_with_domain(self):
         return Response({

+ 1 - 0
webapp/package.json

@@ -11,6 +11,7 @@
   },
   "dependencies": {
     "@mdi/font": "^3.6.95",
+    "axios": "^0.19.0",
     "core-js": "^3.3.2",
     "roboto-fontface": "*",
     "vue": "^2.6.10",

二进制
webapp/public/favicon.ico


+ 1 - 1
webapp/public/index.html

@@ -5,7 +5,7 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
     <link rel="icon" href="<%= BASE_URL %>favicon.ico">
-    <title>webapp</title>
+    <title>deSEC – Free Secure DNS</title>
   </head>
   <body>
     <noscript>

+ 106 - 42
webapp/src/App.vue

@@ -1,60 +1,124 @@
 <template>
-  <v-app>
-    <v-app-bar
-      app
-      color="primary"
-      dark
+  <v-app id="inspire">
+    <v-navigation-drawer
+            v-model="drawer"
+            app
+            right
+            disable-resize-watcher
     >
-      <div class="d-flex align-center">
-        <v-img
-          alt="Vuetify Logo"
-          class="shrink mr-2"
-          contain
-          src="https://cdn.vuetifyjs.com/images/logos/vuetify-logo-dark.png"
-          transition="scale-transition"
-          width="40"
-        />
+      <v-list dense>
+        <v-list-item
+                v-for="(item, key) in menu"
+                :key="key"
+                link
+                :to="{name: item.name}"
+                :exact="true">
+          <v-list-item-action>
+            <v-icon>{{item.icon}}</v-icon>
+          </v-list-item-action>
+          <v-list-item-content>
+            <v-list-item-title>{{item.text}}</v-list-item-title>
+          </v-list-item-content>
+        </v-list-item>
+      </v-list>
+    </v-navigation-drawer>
 
+    <v-app-bar
+            app
+    >
+      <v-toolbar-title><router-link :to="{name: 'home'}">
         <v-img
-          alt="Vuetify Name"
-          class="shrink mt-1 hidden-sm-and-down"
-          contain
-          min-width="100"
-          src="https://cdn.vuetifyjs.com/images/logos/vuetify-name-dark.png"
-          width="100"
-        />
+                :src="require('./assets/logo.svg')"
+                alt="deSEC Logo"
+                contain
+        ></v-img>
+      </router-link></v-toolbar-title>
+      <v-spacer/>
+      <div class="d-none d-md-block">
+        <router-link
+                v-for="(item, key) in menu"
+                :key="key"
+                class="mx-2 primary--text text--darken-2" :to="{name: item.name}"
+        >{{item.text}}</router-link>
       </div>
-
-      <v-spacer></v-spacer>
-
-      <v-btn
-        href="https://github.com/vuetifyjs/vuetify/releases/latest"
-        target="_blank"
-        text
-      >
-        <span class="mr-2">Latest Release</span>
-        <v-icon>mdi-open-in-new</v-icon>
-      </v-btn>
+      <v-btn class="mx-4 mr-0" color="primary" depressed :to="{name: 'signup'}">Create Account</v-btn>
+      <v-app-bar-nav-icon class="d-md-none" @click.stop="drawer = !drawer" />
     </v-app-bar>
 
     <v-content>
-      <HelloWorld/>
+      <router-view/>
     </v-content>
+    <v-footer
+      class="d-flex flex-column align-stretch pa-0 white--text text--darken-1 elevation-12"
+    >
+      <div class="grey darken-3 d-sm-flex flex-row justify-space-between pa-4">
+        <div class="pa-2">
+          <b>deSEC e.V.</b>
+        </div>
+        <div class="d-sm-flex flex-row align-right py-2">
+          <div class="px-2"><a href="//github.com/desec-io/desec-stack/">Source Code</a></div>
+          <div class="px-2"><a href="//encrypt.to/0x7963D427FD32AC6FD20FD0B1EFD6143A3EF22D2F">Contact</a></div>
+          <div class="px-2"><a href="#">Data Protection Policy</a></div>
+        </div>
+      </div>
+      <div class="grey darken-4 d-md-flex flex-row justify-space-between pa-6">
+        <div>
+          <p>{{email}}</p>
+          <p>
+            Kyffhäuserstraße 5<br/>
+            10781 Berlin<br/>
+            Germany
+          </p>
+        </div>
+        <div>
+          <p>Please Donate! 💛</p>
+          <p>
+            European Bank Account:<br>
+            IBAN: DE91&nbsp;8306&nbsp;5408&nbsp;0004&nbsp;1580&nbsp;59<br>
+            BIC: GENODEF1SLR
+          </p>
+        </div>
+        <div>
+          <p>deSEC e.V. is registered as</p>
+          <p>VR37525 at AG Berlin (Charlottenburg)</p>
+        </div>
+        <div>
+          <p>Vorstand</p>
+          <p class="white--text text--darken-2">
+            Nils Wisiol<br/>
+            Dr. Peter Thomassen<br/>
+            Wolfgang Studier<br/>
+          </p>
+        </div>
+      </div>
+    </v-footer>
   </v-app>
 </template>
 
 <script>
-import HelloWorld from './components/HelloWorld';
-
+import {EMAIL} from './env';
 export default {
   name: 'App',
-
-  components: {
-    HelloWorld,
-  },
-
   data: () => ({
-    //
+    drawer: false,
+    email: EMAIL,
+    menu: {
+      'home': {
+        'name': 'home',
+        'icon': 'mdi-home',
+        'text': 'Home',
+      },
+      'docs': {
+        'name': 'docs',
+        'icon': 'mdi-book-open-page-variant',
+        'text': 'API Reference',
+      },
+      'donate': {
+        'name': 'donate',
+        'icon': 'mdi-gift-outline',
+        'text': 'Donate',
+      },
+    }
   }),
-};
+}
 </script>

二进制
webapp/src/assets/logo.png


+ 114 - 1
webapp/src/assets/logo.svg

@@ -1 +1,114 @@
-<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="38.979855mm"
+   height="7.5173831mm"
+   viewBox="0 0 38.979855 7.5173831"
+   version="1.1"
+   id="svg1262"
+   sodipodi:docname="logo.svg"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <defs
+     id="defs1256" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="5.6"
+     inkscape:cx="101.86078"
+     inkscape:cy="8.9271858"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g3885"
+     showgrid="false"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:window-width="2560"
+     inkscape:window-height="1374"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1" />
+  <metadata
+     id="metadata1259">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-254.94057,-266.78298)">
+    <g
+       id="g3885"
+       transform="matrix(0.26519825,0,0,0.26519825,228.89366,215.69135)"
+       style="fill:#000000">
+      <g
+         style="fill:#000000;stroke:#ffffff;stroke-opacity:1"
+         id="layer1-9"
+         transform="matrix(0.22901929,0,0,0.22901929,26.296508,84.906304)"
+         inkscape:export-filename="/home/nils/git/desec-stack/webapp/src/assets/logo.png"
+         inkscape:export-xdpi="567.52002"
+         inkscape:export-ydpi="567.52002">
+        <g
+           style="fill:#000000;stroke:#ffffff;stroke-opacity:1"
+           transform="translate(-194.13584,150.8067)"
+           id="g3933">
+          <path
+             inkscape:connector-curvature="0"
+             d="m 509.13584,366.2239 c 8.87906,-33.13708 42.93987,-52.8021 76.07695,-43.92304 21.43594,5.74374 38.17931,22.48711 43.92305,43.92304 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 z"
+             id="path2985-6-3"
+             style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.99999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker:none;enable-background:accumulate" />
+          <path
+             inkscape:connector-curvature="0"
+             d="m 567.42674,364.89583 v 61.87321 c 0,9.34738 5.48085,16.17306 12.23879,16.17306 6.75795,0 12.23635,-6.83606 12.23635,-16.18344 0,0 -1.07806,-1.02674 -1.75904,-1.03964 -0.64261,-0.0122 -1.69589,0.91753 -1.69589,0.91753 0,6.70817 -3.93157,13.01592 -8.78142,13.01592 -4.84984,0 -8.78142,-6.30775 -8.78142,-13.01592 l -7.6e-4,-61.74072 z"
+             id="path3775-7-4-6"
+             style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:117.14173126;stroke-opacity:1;marker:none;enable-background:accumulate" />
+        </g>
+      </g>
+      <g
+         aria-label="deSEC"
+         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:'Linux Biolinum O';-inkscape-font-specification:'Linux Biolinum O';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
+         id="text3809">
+        <path
+           d="m 154.14496,216.08074 c -1.48,1.88 -3.32,2.84 -4.92,2.84 -2.16,0 -4,-2.16 -4,-7.16 0,-6 3.16,-7.12 5.12,-7.12 1.88,0 2.84,0.8 3.8,2.32 z m 0,2.2 h 0.08 l 0.2,2.32 c 0,0.08 0.12,0.12 0.36,0.12 0.52,-0.04 0.8,-0.12 1.36,-0.12 0.52,0 1.28,0.04 1.8,0.12 l 0.08,-0.12 c -0.32,-1.72 -0.68,-4.68 -0.68,-7.68 v -12.32 c 0,-2.96 0.16,-5.04 0.36,-7.52 0,-0.28 -0.12,-0.4 -0.36,-0.4 -1.04,0.4 -1.88,0.68 -3.48,0.8 l -0.08,0.12 c 0.28,1.72 0.36,4.68 0.36,7.72 v 2.6 c -0.92,-0.52 -2.56,-0.88 -3.36,-0.88 -5.24,0 -9.04,3.6 -9.04,9.04 0,4.92 2.92,8.92 7.24,8.92 1.92,0 3.72,-0.84 5.16,-2.72 z"
+           style="font-size:40px;line-height:1.25;fill:#000000"
+           id="path3774" />
+        <path
+           d="m 165.87683,209.68074 c 0.68,-4.16 3.28,-5.04 4.56,-5.04 1.56,0 3.36,1.48 3.36,4.48 0,0.36 -0.16,0.56 -0.56,0.56 z m 10.68,6.76 c -1.4,1.52 -3.28,2.2 -5.48,2.2 -1.4,0 -3.28,-0.52 -4.36,-2.28 -0.72,-1.12 -0.96,-2.72 -0.96,-5.08 h 10.88 c 0.44,0 0.72,-0.24 0.72,-0.68 0,-3.36 -1.64,-7.56 -6.92,-7.56 -4.12,0 -8.2,3.32 -8.2,9.2 0,2.28 0.44,4.52 1.8,6.12 1.36,1.68 3.64,2.64 6.36,2.64 2.88,0 5.44,-1.48 6.96,-3.56 z"
+           style="font-size:40px;line-height:1.25;fill:#000000"
+           id="path3776" />
+        <path
+           d="m 188.68183,219.24074 c -2.24,0 -5.12,-1.96 -6.36,-4.32 l -0.4,0.04 c -0.16,1.44 -0.6,2.92 -0.92,4.12 l 0.08,0.12 c 0,0 2.6,1.8 7.28,1.8 4.92,0 8.72,-3 8.72,-7.64 0,-4.64 -3.96,-6.76 -7.16,-8 -2.04,-0.76 -4.88,-1.92 -4.88,-5.24 0,-1.44 0.8,-3.04 1.84,-3.6 0.68,-0.36 1.52,-0.48 2.44,-0.48 2.2,0 4.36,1.72 5.44,4.2 l 0.4,-0.04 c 0.16,-1.44 0.56,-2.8 0.92,-4 l -0.08,-0.12 c 0,0 -1.68,-1.8 -6.36,-1.8 -1.08,0 -2.28,0.2 -3.44,0.68 -2.36,1 -4.4,3.32 -4.4,6.24 0,4.2 3.56,6.16 6.88,7.56 2.6,1.08 4.68,2.4 4.68,5.8 0,3 -2.32,4.68 -4.68,4.68 z"
+           style="font-size:40px;line-height:1.25;fill:#000000"
+           id="path3778" />
+        <path
+           d="m 206.83621,212.60074 v -4.68 c 1.92,0 6.16,0.12 8.92,0.4 l 0.12,-0.12 c -0.08,-0.36 -0.12,-0.92 -0.12,-1.28 0,-0.36 0.04,-0.92 0.12,-1.28 l -0.12,-0.12 c -2.36,0.2 -4.2,0.4 -8.92,0.4 v -3.12 c 0,-0.68 0.04,-4.8 0.32,-5.76 5.36,0 10.88,0.48 10.88,0.48 l 0.08,-0.16 c -0.04,-0.32 -0.08,-0.68 -0.08,-1.04 0,-0.32 0.04,-0.96 0.08,-1.52 l -0.08,-0.12 c -0.64,0.08 -1.56,0.12 -2.4,0.12 h -10.52 c -1.36,0 -2.04,-0.12 -2.04,-0.12 l -0.08,0.12 c 0.32,2.28 0.4,5 0.4,8 v 9.8 c 0,3 -0.08,5.84 -0.4,8 l 0.04,0.12 c 0,0 0.68,-0.12 2.08,-0.12 h 10.92 c 0.84,0 1.76,0.04 2.4,0.12 l 0.08,-0.12 c -0.04,-0.56 -0.08,-0.84 -0.08,-1.24 0,-0.4 0.04,-1 0.08,-1.32 l -0.08,-0.16 c 0,0 -5.92,0.48 -11.28,0.48 -0.28,-0.96 -0.32,-5.08 -0.32,-5.76 z"
+           style="font-size:40px;line-height:1.25;fill:#000000"
+           id="path3780" />
+        <path
+           d="m 235.92058,194.28074 c -6.76,0 -13.12,5.84 -13.12,13.72 0,6.92 3.96,13 12.56,13 3.68,0 7.16,-1.12 9.84,-4.36 -0.04,-0.48 -0.12,-1.36 -0.24,-1.72 l -0.28,-0.12 c -2.84,3.12 -5.24,4.16 -8.96,4.16 -5.08,0 -8.8,-5.48 -8.8,-11.72 0,-8.12 5.16,-11.08 8.44,-11.08 3.6,0 6.52,1.44 8.16,4.72 l 0.48,-0.04 c 0.12,-2.04 0.28,-2.72 0.72,-4.44 l -0.08,-0.12 c 0,0 -3.84,-2 -8.72,-2 z"
+           style="font-size:40px;line-height:1.25;fill:#000000"
+           id="path3782" />
+      </g>
+    </g>
+  </g>
+</svg>

+ 103 - 0
webapp/src/assets/logo.text.svg

@@ -0,0 +1,103 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="38.979855mm"
+   height="7.5173831mm"
+   viewBox="0 0 38.979855 7.5173831"
+   version="1.1"
+   id="svg1262"
+   sodipodi:docname="logo.svg"
+   inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
+  <defs
+     id="defs1256" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="5.6"
+     inkscape:cx="101.86078"
+     inkscape:cy="8.9271858"
+     inkscape:document-units="mm"
+     inkscape:current-layer="g3885"
+     showgrid="false"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:window-width="1920"
+     inkscape:window-height="1043"
+     inkscape:window-x="4480"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1" />
+  <metadata
+     id="metadata1259">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-254.94057,-266.78298)">
+    <g
+       id="g3885"
+       transform="matrix(0.26519825,0,0,0.26519825,228.89366,215.69135)"
+       style="fill:#000000">
+      <g
+         style="fill:#000000;stroke:#ffffff;stroke-opacity:1"
+         id="layer1-9"
+         transform="matrix(0.22901929,0,0,0.22901929,26.296508,84.906304)"
+         inkscape:export-filename="/home/nils/git/desec-stack/webapp/src/assets/logo.png"
+         inkscape:export-xdpi="567.52002"
+         inkscape:export-ydpi="567.52002">
+        <g
+           style="fill:#000000;stroke:#ffffff;stroke-opacity:1"
+           transform="translate(-194.13584,150.8067)"
+           id="g3933">
+          <path
+             inkscape:connector-curvature="0"
+             d="m 509.13584,366.2239 c 8.87906,-33.13708 42.93987,-52.8021 76.07695,-43.92304 21.43594,5.74374 38.17931,22.48711 43.92305,43.92304 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 0,0 -6.09923,-6.07815 -10,-6.07815 -3.90077,0 -10,6.07815 -10,6.07815 z"
+             id="path2985-6-3"
+             style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.99999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker:none;enable-background:accumulate" />
+          <path
+             inkscape:connector-curvature="0"
+             d="m 567.42674,364.89583 v 61.87321 c 0,9.34738 5.48085,16.17306 12.23879,16.17306 6.75795,0 12.23635,-6.83606 12.23635,-16.18344 0,0 -1.07806,-1.02674 -1.75904,-1.03964 -0.64261,-0.0122 -1.69589,0.91753 -1.69589,0.91753 0,6.70817 -3.93157,13.01592 -8.78142,13.01592 -4.84984,0 -8.78142,-6.30775 -8.78142,-13.01592 l -7.6e-4,-61.74072 z"
+             id="path3775-7-4-6"
+             style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:117.14173126;stroke-opacity:1;marker:none;enable-background:accumulate" />
+        </g>
+      </g>
+      <text
+         id="text3809"
+         y="220.60074"
+         x="139.70496"
+         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:'Linux Biolinum O';-inkscape-font-specification:'Linux Biolinum O';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
+         xml:space="preserve"
+         inkscape:export-filename="/home/nils/git/desec-stack/webapp/src/assets/logo.png"
+         inkscape:export-xdpi="567.52002"
+         inkscape:export-ydpi="567.52002"><tspan
+           y="220.60074"
+           x="139.70496"
+           id="tspan3811"
+           sodipodi:role="line"
+           style="font-size:40px;line-height:1.25;fill:#000000">deSEC</tspan></text>
+    </g>
+  </g>
+</svg>

二进制
webapp/src/assets/non-free/sse.logo.png


+ 0 - 144
webapp/src/components/HelloWorld.vue

@@ -1,144 +0,0 @@
-<template>
-  <v-container>
-    <v-layout
-      text-center
-      wrap
-    >
-      <v-flex xs12>
-        <v-img
-          :src="require('../assets/logo.svg')"
-          class="my-3"
-          contain
-          height="200"
-        ></v-img>
-      </v-flex>
-
-      <v-flex mb-4>
-        <h1 class="display-2 font-weight-bold mb-3">
-          Welcome to Vuetify
-        </h1>
-        <p class="subheading font-weight-regular">
-          For help and collaboration with other Vuetify developers,
-          <br>please join our online
-          <a href="https://community.vuetifyjs.com" target="_blank">Discord Community</a>
-        </p>
-      </v-flex>
-
-      <v-flex
-        mb-5
-        xs12
-      >
-        <h2 class="headline font-weight-bold mb-3">What's next?</h2>
-
-        <v-layout justify-center>
-          <a
-            v-for="(next, i) in whatsNext"
-            :key="i"
-            :href="next.href"
-            class="subheading mx-3"
-            target="_blank"
-          >
-            {{ next.text }}
-          </a>
-        </v-layout>
-      </v-flex>
-
-      <v-flex
-        xs12
-        mb-5
-      >
-        <h2 class="headline font-weight-bold mb-3">Important Links</h2>
-
-        <v-layout justify-center>
-          <a
-            v-for="(link, i) in importantLinks"
-            :key="i"
-            :href="link.href"
-            class="subheading mx-3"
-            target="_blank"
-          >
-            {{ link.text }}
-          </a>
-        </v-layout>
-      </v-flex>
-
-      <v-flex
-        xs12
-        mb-5
-      >
-        <h2 class="headline font-weight-bold mb-3">Ecosystem</h2>
-
-        <v-layout justify-center>
-          <a
-            v-for="(eco, i) in ecosystem"
-            :key="i"
-            :href="eco.href"
-            class="subheading mx-3"
-            target="_blank"
-          >
-            {{ eco.text }}
-          </a>
-        </v-layout>
-      </v-flex>
-    </v-layout>
-  </v-container>
-</template>
-
-<script>
-export default {
-  name: 'HelloWorld',
-
-  data: () => ({
-    ecosystem: [
-      {
-        text: 'vuetify-loader',
-        href: 'https://github.com/vuetifyjs/vuetify-loader',
-      },
-      {
-        text: 'github',
-        href: 'https://github.com/vuetifyjs/vuetify',
-      },
-      {
-        text: 'awesome-vuetify',
-        href: 'https://github.com/vuetifyjs/awesome-vuetify',
-      },
-    ],
-    importantLinks: [
-      {
-        text: 'Documentation',
-        href: 'https://vuetifyjs.com',
-      },
-      {
-        text: 'Chat',
-        href: 'https://community.vuetifyjs.com',
-      },
-      {
-        text: 'Made with Vuetify',
-        href: 'https://madewithvuejs.com/vuetify',
-      },
-      {
-        text: 'Twitter',
-        href: 'https://twitter.com/vuetifyjs',
-      },
-      {
-        text: 'Articles',
-        href: 'https://medium.com/vuetify',
-      },
-    ],
-    whatsNext: [
-      {
-        text: 'Explore components',
-        href: 'https://vuetifyjs.com/components/api-explorer',
-      },
-      {
-        text: 'Select a layout',
-        href: 'https://vuetifyjs.com/layout/pre-defined',
-      },
-      {
-        text: 'Frequently Asked Questions',
-        href: 'https://vuetifyjs.com/getting-started/frequently-asked-questions',
-      },
-    ],
-  }),
-};
-</script>

+ 1 - 0
webapp/src/env.js.template

@@ -1,2 +1,3 @@
 export const DESECSTACK_DOMAIN = '${DESECSTACK_DOMAIN}';
 export const LOCAL_PUBLIC_SUFFIXES = ['dedyn.${DESECSTACK_DOMAIN}'];
+export const EMAIL = atob(atob('YzNWd2NHOXlkRUJrWlhObFl5NXBidz09'));

+ 13 - 1
webapp/src/plugins/vuetify.js

@@ -1,7 +1,19 @@
 import Vue from 'vue';
 import Vuetify from 'vuetify/lib';
+import colors from 'vuetify/lib/util/colors'
+
 
 Vue.use(Vuetify);
 
+
 export default new Vuetify({
-});
+  theme: {
+    themes: {
+      light: {
+        primary: colors.amber,
+        secondary: colors.lightBlue.darken1,
+        accent: colors.amber.accent4,
+      },
+    },
+  },
+})

+ 19 - 4
webapp/src/router/index.js

@@ -11,13 +11,28 @@ const routes = [
     component: Home
   },
   {
-    path: '/about',
-    name: 'about',
+    path: '/signup/:email?',
+    name: 'signup',
     // route level code-splitting
     // this generates a separate chunk (about.[hash].js) for this route
     // which is lazy-loaded when the route is visited.
-    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
-  }
+    component: () => import(/* webpackChunkName: "signup" */ '../views/SignUp.vue')
+  },
+  {
+    path: '/dynsetup/:domain?',
+    name: 'dynsetup',
+    component: () => import(/* webpackChunkName: "signup" */ '../views/DynSetup.vue')
+  },
+  {
+    path: '/welcome/:domain?',
+    name: 'welcome',
+    component: () => import(/* webpackChunkName: "signup" */ '../views/Welcome.vue')
+  },
+  {
+    path: '//desec.readthedocs.io/',
+    name: 'docs',
+    beforeEnter(to) { location.href = to.path },
+  },
 ]
 
 const router = new VueRouter({

+ 2 - 0
webapp/src/validation.js

@@ -0,0 +1,2 @@
+export const domain_pattern = /^[a-z0-9_.-]*[a-z]$/i;
+export const email_pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

+ 0 - 5
webapp/src/views/About.vue

@@ -1,5 +0,0 @@
-<template>
-  <div class="about">
-    <h1>This is an about page</h1>
-  </div>
-</template>

+ 262 - 0
webapp/src/views/DynSetup.vue

@@ -0,0 +1,262 @@
+<template>
+  <v-container
+          class="fill-height"
+          fluid
+  >
+    <v-row
+            align="center"
+            justify="center"
+    >
+      <v-col
+              cols="12"
+              sm="8"
+              md="6"
+      >
+        <v-card class="elevation-12">
+          <v-toolbar
+                  color="primary"
+                  dark
+                  flat
+          >
+            <v-toolbar-title>Domain Registration Completed</v-toolbar-title>
+          </v-toolbar>
+          <v-card-text>
+            <v-alert :value="!!(errors && errors.length)" type="error">
+              <div v-if="errors.length > 1">
+                <li v-for="error of errors" :key="error.message" >
+                  <b>{{ error.message }}</b>
+                  {{ error }}
+                </li>
+              </div>
+              <div v-else>
+                {{ errors[0] }}
+              </div>
+            </v-alert>
+            <p>
+              Congratulations, you are now the owner of <span class="fixed-width">{{ $route.params.domain }}</span>.
+            </p>
+            <h2 class="title">Set Up Your Domain</h2>
+            <p>
+              All operations on your domain require the following authorization token listed below:
+            </p>
+            <p align="center">
+              <code>{{token}}</code>
+            </p>
+            <p>
+              Please keep this token in a safe place.
+              If lost, it cannot be recovered and must be replaced with a new token.
+            </p>
+            <p>
+              There are several options to connect your new domain name to an IP address.
+              Choose an option that is right for you, then confirm that your setup is working using the check below.
+            </p>
+            <v-expansion-panels class="mb-4" focusable>
+              <v-expansion-panel>
+                <v-expansion-panel-header class="subtitle-1">Configure Your Router</v-expansion-panel-header>
+                <v-expansion-panel-content>
+                  <p>
+                    To continuously update your domain to point to your home router, configure your
+                    router or any other dynamic DNS client in your network with the following parameters:
+                  </p>
+
+                  <v-simple-table>
+                    <tbody>
+                    <tr>
+                      <td>URL</td>
+                      <td class="fixed-width">https://update.{{LOCAL_PUBLIC_SUFFIXES[0]}}/</td>
+                    </tr>
+                    <tr>
+                      <td>Username</td>
+                      <td class="fixed-width">{{domain}}</td>
+                    </tr>
+                    <tr>
+                      <td>Password</td>
+                      <td class="fixed-width">{{token}}</td>
+                    </tr>
+                    </tbody>
+                  </v-simple-table>
+
+                  <p>
+                    Please only update your IP address when it has changed. If your client is
+                    unable to determine when your address changes, please refer to our
+                    <a href="https://desec.readthedocs.io/en/latest/#configuring-your-dyndns-client">documentation</a>
+                    for alternative IP update approaches.
+                  </p>
+                </v-expansion-panel-content>
+              </v-expansion-panel>
+              <v-expansion-panel>
+                <v-expansion-panel-header class="subtitle-1">One-Off Manual Update</v-expansion-panel-header>
+                <v-expansion-panel-content>
+                  <p>
+                    Your domain can be configured to your current public IP address as seen by our servers.
+                    To update your IP, open the following link in any way.
+                  </p>
+                  <p>
+                    <a :href="updateURL" class="fixed-width">{{ updateURL }}</a>
+                  </p>
+                </v-expansion-panel-content>
+              </v-expansion-panel>
+              <v-expansion-panel>
+                <v-expansion-panel-header class="subtitle-1">Alternative IP Update Approaches</v-expansion-panel-header>
+                <v-expansion-panel-content>
+                  <p>
+                    For alternative approaches to updating your IP address and for a
+                    detailed explanation of the update protocol, please refer to our
+                    <a href="https://desec.readthedocs.io/en/latest/#dyndns-howto">documentation</a>.
+                  </p>
+                </v-expansion-panel-content>
+              </v-expansion-panel>
+            </v-expansion-panels>
+
+            <h2 class="title">Check Domain Status</h2>
+            <v-alert type="info" v-if="ips !== undefined && ips.length === 0">
+              <p>
+                Currently, no IPv4 or IPv6 address is associated with
+                <span class="fixed-width">{{ $route.params.domain }}</span>.
+                Please verify that your client is using the credentials provided by deSEC and then come back to check
+                again.
+              </p>
+              <v-btn flat depressed outlined block @click="check" :loading="working">Check Again</v-btn>
+            </v-alert>
+            <v-alert type="success" v-if="ips !== undefined && ips.length > 0">
+              <p>
+                The IP <span v-if="ips.length > 1">addresses</span><span v-if="ips.length === 1">address</span>
+                associated with <span class="fixed-width">{{ $route.params.domain }} </span>
+                <span v-if="ips.length > 1">are:</span><span v-if="ips.length === 1">is:</span>
+              </p>
+              <ul class="mb-4">
+                <li v-for="ip in ips" :key="ip"><span class="fixed-width">{{ip}}</span></li>
+              </ul>
+              <p>
+                The last time your DNS information changed was at {{lastChanged}}.
+              </p>
+              <p>
+                Your deSEC account setup looks good and is ready to use.
+                Enjoy!
+              </p>
+              <p>
+                <v-btn flat depressed outlined block @click="check" :loading="working">Update</v-btn>
+              </p>
+              <p>
+                Please note that deSEC only assigns your IP address to your domain name.
+                To connect to services on your domain, further configuration of your firewall etc. may be necessary.
+              </p>
+            </v-alert>
+
+            <h2 class="title">Keep deSEC Going</h2>
+            <p>
+              To offer free DNS hosting for everyone, deSEC relies on donations only.
+              If you like our service, please consider donating.
+            </p>
+            <p>
+              <v-btn
+                      block
+                      outlined
+                      :to="{name: 'signup'}"
+              >Donate</v-btn> <!-- TODO -->
+            </p>
+          </v-card-text>
+          <v-card-actions>
+            <v-spacer />
+          </v-card-actions>
+        </v-card>
+      </v-col>
+    </v-row>
+  </v-container>
+</template>
+
+<script>
+  import axios from 'axios';
+  import {LOCAL_PUBLIC_SUFFIXES} from '../env';
+
+  const HTTP = axios.create({
+    baseURL: '/api/v1/',
+    headers: {
+    },
+  });
+
+  export default {
+    name: 'CheckSetup',
+    data: () => ({
+      working: false,
+      domain: undefined,
+      errors: [],
+      ips: undefined,
+      token: undefined,
+      LOCAL_PUBLIC_SUFFIXES: LOCAL_PUBLIC_SUFFIXES,
+      lastChanged: undefined,
+    }),
+    async mounted() {
+      if ('domain' in this.$route.params && this.$route.params.domain !== undefined) {
+        this.domain = this.$route.params.domain;
+      }
+      this.token = this.$route.hash.substr(1);
+      this.check();
+    },
+    computed: {
+      updateURL: function () {
+        return 'https://update.' + this.LOCAL_PUBLIC_SUFFIXES[0] +
+                '/update?username=' + this.domain + '&password=' + this.token;
+      }
+    },
+    methods: {
+      async check() {
+        this.working = true;
+        this.errors = [];
+        try {
+          let responseDomain = await HTTP.get(`domains/${this.domain}/`, {headers: {'Authorization': `Token ${this.token}`}});
+          this.lastChanged = responseDomain.data.published;
+        } catch (error) {
+          this.ips = undefined;
+          this.errorHandler(error);
+        }
+
+        this.ips = [];
+        try {
+          this.ips = this.ips.concat(await this.retrieveRecords('A') || []);
+          this.ips = this.ips.concat(await this.retrieveRecords('AAAA') || []);
+        } catch (e) {
+          this.ips = undefined;
+        }
+        this.working = false;
+      },
+      async retrieveRecords(qtype) {
+        try {
+          let response = await HTTP.get(
+                  `domains/${this.domain}/rrsets/@/${qtype}/`,
+                  {headers: {'Authorization': `Token ${this.token}`}}
+          );
+          return response.data.records;
+        } catch (error) {
+          return this.errorHandler(error);
+        }
+      },
+      errorHandler(error) {
+        if (error.response) {
+          // status is not 2xx
+          if (error.response.status < 500) {
+            // 3xx or 4xx
+            if (error.response.status === 404) {
+              return null;
+            }
+            this.errors = error.response;
+          } else {
+            // 5xx
+            this.errors = ['Something went wrong at the server, but we currently do not know why. The customer support was already notified.'];
+          }
+        } else if (error.request) {
+          this.errors = ['Cannot contact our servers. Are you offline?'];
+        } else {
+          this.errors = [error.message];
+        }
+        throw this.errors
+      },
+    },
+  };
+</script>
+
+<style lang="scss">
+  .fixed-width {
+    font-family: monospace;
+  }
+</style>

+ 171 - 7
webapp/src/views/Home.vue

@@ -1,18 +1,182 @@
 <template>
-  <div class="home">
-    <img alt="Vue logo" src="../assets/logo.png">
-    <HelloWorld msg="Welcome to Your Vue.js App"/>
+  <div>
+  <v-card outline tile class="pa-md-12 pa-8 elevation-4" style="overflow: hidden">
+    <div class="d-none d-md-block triangle-bg"></div>
+    <v-container class="pa-0">
+      <v-row align="center">
+        <v-col class="col-md-6 col-12 py-8 triangle-fg">
+          <h1 class="display-1 font-weight-bold">Modern DNS Hosting for Everyone</h1>
+          <h3 class="subheading mt-2 py-8 font-weight-regular">
+            <p>
+              deSEC is a <strong>free DNS hosting</strong> service, <strong>designed with security in mind</strong>.
+            </p>
+            <p>
+              Running on <strong>open-source software</strong> and <strong>supported by <a href="//securesystems.de/">SSE</a></strong>,
+              deSEC is free for everyone to use.
+            </p>
+          </h3>
+          <div class="pa-2 mt-4">
+            <v-form @submit.prevent="signup" :value="valid" ref="form">
+              <v-row>
+                <v-col md="9" cols="12">
+                  <v-text-field
+                    outlined
+                    solo
+                    flat
+                    v-model="email"
+                    prepend-inner-icon="mdi-email"
+                    type="email"
+                    placeholder="Account Email Address"
+                    :rules="email_rules"
+                    validate-on-blur
+                    ></v-text-field>
+                  <v-btn
+                    color="primary"
+                    type="submit"
+                    depressed
+                    x-large
+                    block
+                  >
+                    Create Account
+                  </v-btn>
+                </v-col>
+              </v-row>
+            </v-form>
+          </div>
+        </v-col>
+      </v-row>
+    </v-container>
+  </v-card>
+  <v-container fluid>
+    <v-container>
+      <v-row class="py-8">
+        <v-col class="col-12 col-sm-4 text-center" v-for="f in features" :key="f.title">
+          <v-icon x-large>{{f.icon}}</v-icon>
+          <h1 class="grey--text text--darken-2"><span>{{f.title}}</span></h1>
+          <p>{{f.text}}</p>
+        </v-col>
+      </v-row>
+    </v-container>
+  </v-container>
+  <v-container fluid class="grey lighten-4">
+    <v-container class="py-8">
+      <v-row align="center">
+        <v-col class="text-center">
+          <h2>Supporters</h2>
+        </v-col>
+      </v-row>
+      <v-row justify="center">
+        <v-col class="col-12 col-lg-2 py-4">
+          <v-layout class="justify-center">
+            <v-img :src="require('../assets/non-free/sse.logo.png')" alt="SSE Logo" class="mr-6" contain style="max-width: 160px; width: 100%"></v-img>
+          </v-layout>
+        </v-col>
+        <v-col class="col-12 col-sm-10 col-lg-8 py-4 text-center">
+          <a class="primary--text text--darken-2" href="//securesystems.de/">SSE</a> supports us with development staff
+          and provides deSEC with our global Anycast networking infrastructure for delivering signed DNS data to the
+          public. We trust them because creating and auditing security solutions is their daily business.
+        </v-col>
+      </v-row>
+    </v-container>
+  </v-container>
   </div>
 </template>
 
+<style>
+  div.triangle-bg {
+    border: 80em solid transparent;
+    border-right: 60em solid #FFC107;
+    position: absolute;
+    right: 0;
+    bottom: -20em;
+    width: 0;
+    z-index: 1;
+  }
+  .triangle-fg {
+    z-index: 2;
+  }
+</style>
+
 <script>
-// @ is an alias to /src
-import HelloWorld from '@/components/HelloWorld.vue'
+import {email_pattern} from "../validation";
 
 export default {
   name: 'home',
   components: {
-    HelloWorld
-  }
+  },
+  methods: {
+    async signup() {
+      if (this.$refs.form.validate()) this.$router.push({name: 'signup', params: this.email !== '' ? {email: this.email} : {}});
+    },
+  },
+  data: () => ({
+    email: '',
+    email_rules: [
+      v => !!email_pattern.test(v || '') || 'Invalid email address.'
+    ],
+    valid: false,
+    features: [
+      {
+        href: '#',
+        icon: 'mdi-lock-outline',
+        title: 'DNSSEC',
+        text: 'DNS information hosted with deSEC is signed using DNSSEC, always.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-ip-network-outline',
+        title: 'IPv6',
+        text: 'deSEC is fully IPv6-aware: administration can be done using v6, AAAA-records '
+                + 'containing IPv6 addresses can be set up, our name servers are reachable via IPv6.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-certificate',
+        title: 'DANE / TLSA',
+        text: 'Secure your web service with TLSA records, hardening it against fraudulently issued SSL '
+                + 'certificates. You can also use other DANE techniques, such as OPENPGPKEY key exchange.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-robot',
+        title: 'REST API',
+        text: 'Configure your DNS information via a modern API. You can easily integrate our API into your scripts, '
+              + 'tools, or even CI/CD pipeline.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-run-fast',
+        title: 'Fast Updates',
+        text: 'Updates to your DNS information will be published by deSEC within a few seconds. '
+                + 'Minimum required TTLs are low.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-flower',
+        title: 'Open Source',
+        text: 'deSEC runs 100% on free open-source software. Start hacking away ...',
+      },
+      {
+        href: '#',
+        icon: 'mdi-lan',
+        title: 'Low-latency Anycast',
+        text: 'We run a global network of 8 high-performance frontend DNS servers (Europe, US, Asia). Your query is '
+              + 'routed to the closest server via Anycast, so clients receive answers as fast as possible.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-gift',
+        title: 'Non-profit',
+        text: 'deSEC is organized as a non-profit charitable organization based in Berlin. We make sure that privacy '
+              + 'is not compromised by business interest.',
+      },
+      {
+        href: '#',
+        icon: 'mdi-file-certificate',
+        title: "Let's Encrypt Integration",
+        text: "We provide easy integration with Let's Encrypt and their certbot tool.",
+      },
+    ],
+  })
 }
 </script>

+ 274 - 0
webapp/src/views/SignUp.vue

@@ -0,0 +1,274 @@
+<template>
+  <v-container
+          class="fill-height"
+          fluid
+  >
+    <v-row
+            align="center"
+            justify="center"
+    >
+      <v-col
+              cols="12"
+              sm="8"
+              md="6"
+      >
+        <v-form @submit.prevent="signup" ref="form">
+          <v-card class="elevation-12 pb-4">
+            <v-toolbar
+                    color="primary"
+                    dark
+                    flat
+            >
+              <v-toolbar-title>Create new Account</v-toolbar-title>
+            </v-toolbar>
+            <v-card-text>
+              <v-alert :value="!!(errors && errors.length)" type="error">
+                <div v-if="errors.length > 1">
+                  <li v-for="error of errors" :key="error.message" >
+                    <b>{{ error.message }}</b>
+                    {{ error }}
+                  </li>
+                </div>
+                <div v-else>
+                  {{ errors[0] }}
+                </div>
+              </v-alert>
+
+              <v-text-field
+                      v-model="email"
+                      label="Email"
+                      prepend-icon="mdi-email"
+                      outline
+                      required
+                      :disabled="working"
+                      :rules="email_rules"
+                      :error-messages="email_errors"
+                      @change="email_errors=[]"
+                      validate-on-blur
+                      ref="emailField"
+                      tabindex="1"
+              />
+
+              <p class="mt-4 pl-8 heading">
+                To use our <strong>dynDNS service</strong>, enter a domain name here. After sign-up, we will send you
+                instructions on how to configure your dynDNS client, such as you router.<br>
+                If instead you are interested in <strong>general DNS hosting</strong>, please do not provide a domain
+                name. After sign-up, you can login and create a token to use our DNS REST API.
+              </p>
+
+              <v-text-field
+                      v-model="domain"
+                      label="DynDNS domain (optional)"
+                      prepend-icon="mdi-dns"
+                      outline
+                      required
+                      :disabled="working"
+                      :rules="domain_rules"
+                      :error-messages="domain_errors"
+                      :suffix="'.' + LOCAL_PUBLIC_SUFFIXES[0]"
+                      @change="domain_errors=[]"
+                      class="lowercase"
+                      ref="domainField"
+                      tabindex="2"
+              />
+
+              <v-layout>
+                <v-text-field
+                        v-model="captchaSolution"
+                        label="Type CAPTCHA text here"
+                        prepend-icon="mdi-account-check"
+                        outline
+                        required
+                        :disabled="working"
+                        :rules="captcha_rules"
+                        :error-messages="captcha_errors"
+                        @change="captcha_errors=[]"
+                        @keypress="captcha_errors=[]"
+                        class="uppercase"
+                        ref="captchaField"
+                        tabindex="3"
+                />
+                <div class="ml-4 text-center">
+                  <v-progress-circular
+                          indeterminate
+                          v-if="captchaWorking"
+                  ></v-progress-circular>
+                  <img
+                          v-if="captcha && !captchaWorking"
+                          :src="'data:image/png;base64,'+captcha.challenge"
+                          alt="Sign up is also possible by sending an email to our support."
+                  >
+                  <br/>
+                  <v-btn text outlined @click="getCaptcha(true)" :disabled="captchaWorking">New Captcha</v-btn>
+                </div>
+              </v-layout>
+
+              <v-layout class="justify-center">
+                <v-checkbox
+                      v-model="terms"
+                      type="checkbox"
+                      required
+                      :disabled="working"
+                      :rules="terms_rules"
+                      tabindex="4"
+              >
+                <template slot="label">
+                  <v-flex>Yes, I agree to the <a @click.stop="privacyPolicy">privacy policy</a>.</v-flex>
+                </template>
+              </v-checkbox>
+            </v-layout>
+          </v-card-text>
+          <v-card-actions class="justify-center">
+            <v-btn
+                    depressed
+                    color="primary"
+                    type="submit"
+                    :disabled="working"
+                    :loading="working"
+                    tabindex="5"
+            >Sign Up</v-btn>
+          </v-card-actions>
+        </v-card>
+        </v-form>
+      </v-col>
+    </v-row>
+  </v-container>
+</template>
+
+<script>
+  import axios from 'axios';
+  import {LOCAL_PUBLIC_SUFFIXES} from '../env';
+  import {domain_pattern, email_pattern} from '../validation';
+
+  const HTTP = axios.create({
+    baseURL: '/api/v1/',
+    headers: {
+    },
+  });
+
+  export default {
+    name: 'SignUp',
+    data: () => ({
+      valid: false,
+      working: false,
+      captchaWorking: true,
+      errors: [],
+      captcha: null,
+      LOCAL_PUBLIC_SUFFIXES: LOCAL_PUBLIC_SUFFIXES,
+
+      /* email field */
+      email: '',
+      email_rules: [v => !!email_pattern.test(v || '') || 'We need an email address for account recovery and technical support.'],
+      email_errors: [],
+
+      /* captcha field */
+      captchaSolution: '',
+      captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
+      captcha_errors: [],
+
+      /* terms field */
+      terms: false,
+      terms_rules: [v => !!v || 'You can only use our service if you agree with the terms'],
+
+      /* domain field */
+      domain: '',
+      domain_rules: [v => !!domain_pattern.test(v + '.' + LOCAL_PUBLIC_SUFFIXES[0]) || 'Domain names can only contain letters, numbers, underscores (_), dots (.), and dashes (-), and must end in a letter.'],
+      domain_errors: [],
+    }),
+    async mounted() {
+      if ('email' in this.$route.params && this.$route.params.email !== undefined) {
+        this.email = this.$route.params.email;
+      }
+      this.getCaptcha();
+      this.initialFocus();
+    },
+    methods: {
+      async privacyPolicy() {
+        window.open(this.$router.resolve({name: 'privacy-policy'}).href);
+        this.terms = !this.terms; // silly but easy fix for "accidentally" checking the box by clicking the link
+      },
+      async getCaptcha(focus = false) {
+        this.captchaWorking = true;
+        this.captchaSolution = "";
+        try {
+          this.captcha = (await HTTP.post('captcha/')).data;
+          if(focus) {
+            this.$refs.captchaField.focus()
+          }
+        } finally {
+          this.captchaWorking = false;
+        }
+      },
+      async initialFocus() {
+        return this.email ? this.$refs.domainField.focus() : this.$refs.emailField.focus();
+      },
+      async signup() {
+        if (!this.$refs.form.validate()) {
+          return;
+        }
+        this.working = true;
+        this.errors = [];
+        let domain = this.domain === '' ? undefined : this.domain.toLowerCase() + '.' + this.LOCAL_PUBLIC_SUFFIXES[0];
+        try {
+          await HTTP.post('auth/', {
+            email: this.email.toLowerCase(),
+            password: null,
+            captcha: {
+              id: this.captcha.id,
+              solution: this.captchaSolution.toUpperCase(),
+            },
+            domain: domain,
+          });
+          this.$router.push({name: 'welcome', params: domain !== '' ? {domain: domain} : {}});
+        } catch (error) {
+          if (error.response) {
+            // status is not 2xx
+            if (error.response.status < 500 && typeof error.response.data === 'object') {
+              // 3xx or 4xx
+              let extracted = false;
+              this.getCaptcha(true);
+              if ('captcha' in error.response.data) {
+                if ('non_field_errors' in error.response.data.captcha) {
+                  this.captcha_errors = [error.response.data.captcha.non_field_errors[0]];
+                  extracted = true;
+                }
+                if ('solution' in error.response.data.captcha) {
+                  this.captcha_errors = error.response.data.captcha.solution;
+                  extracted = true;
+                }
+              }
+              if ('domain' in error.response.data) {
+                this.domain_errors = [error.response.data.domain[0]];
+                extracted = true;
+              }
+              if ('email' in error.response.data) {
+                this.email_errors = [error.response.data.email[0]];
+                extracted = true;
+              }
+              if (!extracted) {
+                this.errors = error.response;
+              }
+            } else {
+              // 5xx
+              this.errors = ['Something went wrong at the server, but we currently do not know why. The customer support was already notified.'];
+            }
+          } else if (error.request) {
+            this.errors = ['Cannot contact our servers. Are you offline?'];
+          } else {
+            this.errors = [error.message];
+          }
+        }
+        this.working = false;
+      },
+    },
+  };
+</script>
+
+<style lang="scss">
+  .uppercase input {
+    text-transform: uppercase;
+  }
+  .lowercase input {
+    text-transform: lowercase;
+  }
+</style>

+ 41 - 0
webapp/src/views/Welcome.vue

@@ -0,0 +1,41 @@
+<template>
+  <v-container
+          class="fill-height"
+          fluid
+  >
+    <v-row
+            align="center"
+            justify="center"
+    >
+      <v-col
+              cols="12"
+              sm="8"
+              md="6"
+      >
+        <v-form @submit.prevent="signup" ref="form">
+        <v-card class="elevation-12">
+          <v-toolbar
+                  color="primary"
+                  dark
+                  flat
+          >
+            <v-toolbar-title>Welcome to deSEC</v-toolbar-title>
+          </v-toolbar>
+          <v-card-text>
+            <v-alert type="success">
+              Thank you for your interest in deSEC.
+              To create your deSEC account, please click the verification link in the email that we sent you.
+              If you did not receive an email, please check your spam folder or if you already have an account
+              by using the <router-link to="/">account recovery form</router-link>. <!-- TODO -->
+            </v-alert>
+          </v-card-text>
+          <v-card-actions>
+            <v-spacer/>
+            <v-btn outlined text depressed :to="{name: 'home'}">Home</v-btn>
+          </v-card-actions>
+        </v-card>
+        </v-form>
+      </v-col>
+    </v-row>
+  </v-container>
+</template>